import 'dart:math'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/app_toast.dart'; import '../../providers/data_providers.dart'; import '../../widgets/common_widgets.dart'; import '../../widgets/app_empty_state.dart'; const String defaultTrendMetricType = 'blood_pressure'; const Set supportedTrendMetricTypes = { 'blood_pressure', 'heart_rate', 'glucose', 'spo2', 'weight', }; class _TrendColors { _TrendColors._(); static const bloodPressure = HealthMetricVisuals.bloodPressureColor; static const heartRate = HealthMetricVisuals.heartRateColor; static const glucose = HealthMetricVisuals.glucoseColor; static const spo2 = HealthMetricVisuals.spo2Color; static const weight = HealthMetricVisuals.weightColor; static const systolic = bloodPressure; static const diastolic = Color(0xFF3B82F6); } String normalizeTrendMetricType(String? metricType) { if (metricType == null) return defaultTrendMetricType; return supportedTrendMetricTypes.contains(metricType) ? metricType : defaultTrendMetricType; } List> 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': HealthMetricVisuals.bloodPressureIcon, }, { 'key': 'heart_rate', 'label': '心率', 'color': _TrendColors.heartRate, 'icon': HealthMetricVisuals.heartRateIcon, }, { 'key': 'glucose', 'label': '血糖', 'color': _TrendColors.glucose, 'icon': HealthMetricVisuals.glucoseIcon, }, { 'key': 'spo2', 'label': '血氧', 'color': _TrendColors.spo2, 'icon': HealthMetricVisuals.spo2Icon, }, { 'key': 'weight', 'label': '体重', 'color': _TrendColors.weight, 'icon': HealthMetricVisuals.weightIcon, }, ]; static const _units = { 'blood_pressure': 'mmHg', 'heart_rate': 'bpm', 'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg', }; static const _names = { 'blood_pressure': '血压', 'heart_rate': '心率', 'glucose': '血糖', 'spo2': '血氧', 'weight': '体重', }; static const _latestCardArt = { 'blood_pressure': 'assets/branding/trend_metric_blood_pressure.png', 'heart_rate': 'assets/branding/trend_metric_heart_rate.png', 'glucose': 'assets/branding/trend_metric_glucose.png', 'spo2': 'assets/branding/trend_metric_spo2.png', 'weight': 'assets/branding/trend_metric_weight.png', }; String get _unit => _units[_selected] ?? ''; String get _name => _names[_selected] ?? ''; bool get _isBP => _selected == 'blood_pressure'; String get _selectedApiType => _apiTypeForMetric(_selected); List> 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) => _KeyboardInsetPadding( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '录入$_name', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700), ), const SizedBox(height: 16), if (_isBP) ...[ Row( children: [ Expanded( child: TextField( controller: ctrl1, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '收缩压 (mmHg)', border: OutlineInputBorder(), ), ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: ctrl2, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '舒张压 (mmHg)', border: OutlineInputBorder(), ), ), ), ], ), ] else TextField( controller: ctrl1, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: '$_name ($_unit)', border: const OutlineInputBorder(), ), ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: AppGradientOutlineButton( label: '确认录入', onPressed: () async { final v1 = double.tryParse(ctrl1.text.trim()); final v2 = _isBP ? double.tryParse(ctrl2.text.trim()) : null; if (v1 == null || (_isBP && v2 == null)) { AppToast.show( ctx, _isBP ? '请完整填写收缩压和舒张压' : '请输入有效的$_name数值', type: AppToastType.error, ); return; } if (_isBP && (v1 < 50 || v1 > 250 || v2! < 30 || v2 > 150 || v1 <= v2)) { AppToast.show( ctx, '请检查血压数值,收缩压应高于舒张压', type: AppToastType.error, ); return; } try { final api = ref.read(apiClientProvider); final body = { 'type': _selectedApiType, 'source': 'Manual', 'recordedAt': DateTime.now().toUtc().toIso8601String(), 'unit': _unit, }; if (_isBP) { body['systolic'] = v1.toInt(); body['diastolic'] = v2!.toInt(); } else { body['value'] = v1; } await api.post('/api/health-records', data: body); if (!ctx.mounted || !mounted) { return; } Navigator.pop(ctx); ref.invalidate(latestHealthProvider); _loadAll(); } catch (_) { if (!mounted) { return; } AppToast.show( context, '录入失败,请检查数值或网络后重试', type: AppToastType.error, ); } }, ), ), const SizedBox(height: 20), ], ), ), ); } @override Widget build(BuildContext context) { return GradientScaffold( resizeToAvoidBottomInset: false, appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref), ), title: const Text('健康概览'), centerTitle: true, ), floatingActionButton: AppCreateFab( tooltip: '添加健康记录', onPressed: _showAddDialog, ), body: _loading ? const Center( child: CircularProgressIndicator(color: AppTheme.primary), ) : RefreshIndicator( onRefresh: _loadAll, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(14), child: Column( children: [ _buildMetricOverview(), const SizedBox(height: 16), _buildTrendInsightPanel(), const SizedBox(height: 24), _buildHistory(), const SizedBox(height: 80), ], ), ), ), ); } // ---- 顶部指标概览,同时作为切换入口 ---- Widget _buildMetricOverview() { return Container( width: double.infinity, padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.pillBorder, border: Border.all(color: AppColors.divider.withValues(alpha: 0.7)), ), child: Row( children: _metrics.map((m) { final key = m['key'] as String; final color = m['color'] as Color; final sel = _selected == key; return Expanded( child: InkWell( onTap: () => _switchMetric(key), borderRadius: AppRadius.pillBorder, child: AnimatedContainer( duration: const Duration(milliseconds: 180), height: 44, padding: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: sel ? color : Colors.transparent, borderRadius: AppRadius.pillBorder, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( m['icon'] as IconData, size: 16, color: sel ? Colors.white : color, ), const SizedBox(width: 4), Flexible( child: Text( m['label'] as String, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: sel ? Colors.white : AppColors.textSecondary, ), ), ), ], ), ), ), ); }).toList(), ), ); } Widget _buildLatestSummary() { final record = _latestRecord!; final date = record['date'] as DateTime; final status = trendStatusLabel(record, _selected); final abnormal = status.isNotEmpty; final art = _latestCardArt[_selected] ?? _latestCardArt['heart_rate']!; return SizedBox( height: 214, child: Stack( clipBehavior: Clip.hardEdge, children: [ Positioned( left: 0, right: -48, top: -12, bottom: -12, child: Image.asset( art, fit: BoxFit.cover, alignment: Alignment.centerRight, ), ), Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ Colors.white.withValues(alpha: 0.96), Colors.white.withValues(alpha: 0.72), Colors.white.withValues(alpha: 0.04), ], stops: const [0, 0.42, 0.78], ), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(6, 8, 178, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: _color.withValues(alpha: 0.12), borderRadius: AppRadius.mdBorder, ), child: Icon( _getMetricIcon(_selected), color: _color, size: 23, ), ), const SizedBox(width: 12), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '最新$_name', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), const SizedBox(height: 3), Text( _formatRecordTime(date), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, color: AppColors.textHint, fontWeight: FontWeight.w500, ), ), ], ), ), ], ), const SizedBox(height: 18), FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Wrap( crossAxisAlignment: WrapCrossAlignment.end, spacing: 7, runSpacing: 4, children: [ Text( _displayValue(record), style: const TextStyle( fontSize: 44, fontWeight: FontWeight.w800, color: AppColors.textPrimary, height: 0.98, ), ), ], ), ), const SizedBox(height: 8), _StatusBadge( label: abnormal ? status : '正常', abnormal: abnormal, ), ], ), ), Positioned( left: 6, right: 178, bottom: 8, child: _buildPeriodSelector(), ), ], ), ); } Widget _buildTrendInsightPanel() { return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.cardBorder, boxShadow: AppShadows.panel, ), child: Column( children: [ if (_latestRecord != null) ...[ _buildLatestSummary(), const SizedBox(height: 4), ], if (_latestRecord == null) ...[ _buildPeriodSelector(), const SizedBox(height: 14), ], _buildChart(), if (_chartRecords.isNotEmpty) ...[ const SizedBox(height: 4), _buildStatistics(), ], ], ), ); } Widget _buildPeriodSelector() { return Container( constraints: const BoxConstraints(maxWidth: 310), padding: const EdgeInsets.all(2), decoration: BoxDecoration( color: _color.withValues(alpha: 0.06), borderRadius: AppRadius.lgBorder, border: Border.all(color: _color.withValues(alpha: 0.14)), ), child: Row( children: [ for (final days in const [7, 30, 90]) Expanded( child: InkWell( onTap: () => setState(() { _periodDays = days; _selectedIdx = null; }), borderRadius: AppRadius.smBorder, child: Container( height: 30, alignment: Alignment.center, decoration: BoxDecoration( color: _periodDays == days ? _color.withValues(alpha: 0.92) : Colors.transparent, borderRadius: AppRadius.smBorder, ), child: Text( '$days天', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: _periodDays == days ? Colors.white : AppColors.textSecondary, ), ), ), ), ), ], ), ); } Widget _buildStatistics() { final records = _chartRecords; final values = records .map((record) => (record['value'] as num?)?.toDouble()) .whereType() .toList(); String average; String highest; String lowest; 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 bpRecords = records.where((record) { return record['systolic'] is num && record['diastolic'] is num; }); final highestRecord = bpRecords.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; }); final lowestRecord = bpRecords.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); lowest = lowestRecord == null ? '--' : _displayValue(lowestRecord); } else { average = values.isEmpty ? '--' : formatTrendNumber(values.reduce((a, b) => a + b) / values.length); highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max)); lowest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(min)); } return Container( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), decoration: BoxDecoration( color: _color.withValues(alpha: 0.045), borderRadius: AppRadius.xlBorder, ), child: Row( children: [ _StatisticItem(label: '平均', value: average, unit: _unit), const SizedBox(height: 46, child: VerticalDivider(width: 1)), _StatisticItem(label: '最高', value: highest, unit: _unit), const SizedBox(height: 46, child: VerticalDivider(width: 1)), _StatisticItem(label: '最低', value: lowest, unit: _unit), ], ), ); } String _displayValue(Map 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 轴刻度间隔:日标签短,可以排密一点 const xInterval = 1.0; final dayStartIndices = []; for (var i = 0; i < records.length; i++) { if (i == 0 || !_isSameChartDay( records[i - 1]['date'] as DateTime, records[i]['date'] as DateTime, )) { dayStartIndices.add(i); } } final dayLabelStep = dayStartIndices.length > 7 ? (dayStartIndices.length / 7).ceil() : 1; final visibleDayLabels = { for (var i = 0; i < dayStartIndices.length; i += dayLabelStep) dayStartIndices[i], if (dayStartIndices.isNotEmpty) dayStartIndices.last, }; return Container( padding: const EdgeInsets.fromLTRB(4, 10, 12, 6), decoration: BoxDecoration( color: Colors.transparent, borderRadius: AppRadius.lgBorder, ), child: Column( children: [ Row( children: [ const SizedBox(width: 8), Text( _name, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: _color, ), ), const SizedBox(width: 6), Text( '趋势 (${records.length}条)', style: const TextStyle( fontSize: 15, color: AppColors.textSecondary, ), ), const Spacer(), Text( '单位: $_unit', style: const TextStyle(fontSize: 14, color: AppColors.textHint), ), ], ), if (_isBP) ...[ const SizedBox(height: 8), const Row( mainAxisAlignment: MainAxisAlignment.end, children: [ _ChartLegend(color: _TrendColors.systolic, label: '收缩压'), SizedBox(width: 16), _ChartLegend(color: _TrendColors.diastolic, label: '舒张压'), ], ), ], const SizedBox(height: 10), SizedBox( height: 200, child: LayoutBuilder( builder: (context, constraints) { final chartWidth = constraints.maxWidth; final paintWidth = chartWidth - 32.0; // 减左侧轴 final paintHeight = 200.0 - 36.0; // 减底部轴 // 计算选中点的像素位置 Offset? tooltipPos; String? tooltipText1; String? tooltipText2; if (_selectedIdx != null && _selectedIdx! >= 0 && _selectedIdx! < records.length) { final r = records[_selectedIdx!]; final v = _isBP ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble(); if (v != null && spots.length > 1) { final px = 32.0 + (_selectedIdx! / (spots.length - 1)) * paintWidth; final py = paintHeight * (1 - (v - minV) / (maxV - minV)); final d = r['date'] as DateTime; tooltipPos = Offset(px, py); tooltipText1 = '${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}'; tooltipText2 = _isBP ? '${r['systolic']}/${r['diastolic']} $_unit' : '$v $_unit'; } } return Stack( clipBehavior: Clip.none, children: [ LineChart( LineChartData( minX: 0, maxX: max(1, spots.length - 1).toDouble(), minY: minV, maxY: maxV, gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: yStep, getDrawingHorizontalLine: (_) => const FlLine( color: AppColors.divider, strokeWidth: 0.8, dashArray: [4, 4], ), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 32, interval: yStep, getTitlesWidget: (v, meta) => Padding( padding: const EdgeInsets.only(right: 6), child: Text( v.toStringAsFixed(0), style: const TextStyle( fontSize: 12, color: AppColors.textHint, fontWeight: FontWeight.w500, ), ), ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 36, interval: xInterval, getTitlesWidget: (v, meta) { final idx = v.toInt(); if (idx < 0 || idx >= records.length) { return const SizedBox.shrink(); } if (!visibleDayLabels.contains(idx)) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 8), child: Text( _chartAxisLabel(records, idx), style: const TextStyle( fontSize: 11, color: AppColors.textHint, fontWeight: FontWeight.w500, ), ), ); }, ), ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), ), lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, curveSmoothness: 0.28, color: _color, barWidth: 3, isStrokeCapRound: true, dotData: FlDotData( show: spots.length <= 30, getDotPainter: (spot, _, _, _) { final isSelected = spot.x.toInt() == _selectedIdx; if (isSelected) { return FlDotCirclePainter( radius: 5.5, color: _color, strokeWidth: 2.5, strokeColor: Colors.white, ); } return FlDotCirclePainter( radius: 3.5, color: Colors.white, strokeWidth: 2, strokeColor: _color, ); }, ), belowBarData: BarAreaData( show: true, gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ _color.withValues(alpha: 0.18), _color.withValues(alpha: 0.02), ], ), ), ), if (_isBP && diastolicSpots.isNotEmpty) LineChartBarData( spots: diastolicSpots, isCurved: true, curveSmoothness: 0.28, color: _TrendColors.diastolic, barWidth: 3, isStrokeCapRound: true, dotData: FlDotData( show: diastolicSpots.length <= 30, getDotPainter: (spot, _, _, _) => FlDotCirclePainter( radius: spot.x.toInt() == _selectedIdx ? 5.5 : 3.5, color: Colors.white, strokeWidth: 2, strokeColor: _TrendColors.diastolic, ), ), belowBarData: BarAreaData( show: true, color: _TrendColors.diastolic.withValues( alpha: 0.05, ), ), ), ], lineTouchData: LineTouchData( handleBuiltInTouches: false, touchCallback: (event, response) { if (event is FlTapUpEvent) { final spots = response?.lineBarSpots; if (spots != null && spots.isNotEmpty) { final idx = spots.first.x.toInt(); setState( () => _selectedIdx = (_selectedIdx == idx) ? null : idx, ); } else { setState(() => _selectedIdx = null); } } }, ), ), duration: Duration.zero, ), if (tooltipPos != null) Positioned( left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0), top: tooltipPos.dy - 56, child: FractionalTranslation( translation: const Offset(-0.5, 0), child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xsBorder, border: Border.all( color: _color.withValues(alpha: 0.35), ), boxShadow: [ BoxShadow( color: const Color( 0xFF101828, ).withValues(alpha: 0.14), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( tooltipText1!, style: const TextStyle( fontSize: 11, color: AppColors.textHint, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 2), Text( tooltipText2!, style: TextStyle( fontSize: 15, color: _color, fontWeight: FontWeight.w700, ), ), ], ), ), ), ), ], ); }, ), ), ], ), ); } String _chartAxisLabel(List> records, int index) { final date = records[index]['date'] as DateTime; return '${date.month}-${date.day}'; } bool _isSameChartDay(DateTime first, DateTime second) { return first.year == second.year && first.month == second.month && first.day == second.day; } // ---- 历史记录列表 ---- Widget _buildHistory() { if (_filtered.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Text( '录入记录', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700), ), const Spacer(), Text( '共${_filtered.length}条', style: const TextStyle( fontSize: 16, color: AppColors.textSecondary, ), ), ], ), const SizedBox(height: 10), Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, boxShadow: AppShadows.soft, ), child: Column( children: [ ..._filtered.reversed.take(30).toList().asMap().entries.map(( entry, ) { final r = entry.value; final isLast = entry.key == _filtered.reversed.take(30).length - 1; final date = r['date'] as DateTime; final abnormal = r['isAbnormal'] == true; final id = r['id']?.toString() ?? ''; final chartRecords = _chartRecords; final selectedId = _selectedIdx != null && _selectedIdx! >= 0 && _selectedIdx! < chartRecords.length ? chartRecords[_selectedIdx!]['id']?.toString() : null; final isSelected = selectedId == id; final display = _displayValue(r); final status = trendStatusLabel(r, _selected); return Dismissible( key: ValueKey('$id-${date.microsecondsSinceEpoch}'), direction: DismissDirection.endToStart, background: Container( color: AppColors.error, alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), child: const Icon( Icons.delete_outline, color: Colors.white, ), ), confirmDismiss: (_) async { try { final api = ref.read(apiClientProvider); await api.delete('/api/health-records/$id'); return true; } catch (_) { if (mounted) { AppToast.show( context, '删除失败,请检查网络后重试', type: AppToastType.error, ); } return false; } }, onDismissed: (_) { setState(() { _allRecords.removeWhere( (record) => record['id']?.toString() == id, ); _filtered.removeWhere( (record) => record['id']?.toString() == id, ); _selectedIdx = null; }); ref.invalidate(latestHealthProvider); }, child: GestureDetector( onTap: () { final chartIndex = chartRecords.indexWhere( (record) => record['id']?.toString() == id, ); if (chartIndex < 0) return; setState( () => _selectedIdx = isSelected ? null : chartIndex, ); }, child: AnimatedContainer( duration: const Duration(milliseconds: 180), color: isSelected ? _color.withValues(alpha: 0.10) : Colors.white, child: Column( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10, ), child: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: _color.withAlpha(20), borderRadius: AppRadius.smBorder, ), child: Center( child: Icon( _getMetricIcon(_selected), size: 19, color: _color, ), ), ), const SizedBox(width: 12), Expanded( child: Row( children: [ Flexible( child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( _formatRecordTime(date), maxLines: 1, style: const TextStyle( fontSize: 14, color: AppColors.textSecondary, fontWeight: FontWeight.w600, ), ), ), ), const SizedBox(width: 6), _StatusBadge( label: abnormal ? status : '正常', abnormal: abnormal, ), ], ), ), const SizedBox(width: 8), SizedBox( width: 116, child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerRight, child: Text( '$display $_unit', maxLines: 1, style: const TextStyle( fontSize: 21, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), ), ), ], ), ), if (!isLast) const Padding( padding: EdgeInsets.only(left: 62), child: Divider( height: 1, thickness: 0.7, color: Color(0xFFE8ECF2), ), ), ], ), ), ), ); }), ], ), ), ], ); } IconData _getMetricIcon(String key) { return _metrics.firstWhere( (metric) => metric['key'] == key, orElse: () => _metrics.first, )['icon'] as IconData; } } class _KeyboardInsetPadding extends StatelessWidget { final Widget child; const _KeyboardInsetPadding({required this.child}); @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, left: 20, right: 20, top: 20, ), child: RepaintBoundary(child: child), ); } } class _StatisticItem extends StatelessWidget { final String label; final String value; final String unit; const _StatisticItem({ required this.label, required this.value, required this.unit, }); @override Widget build(BuildContext context) { return Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( label, style: const TextStyle(fontSize: 13, color: AppColors.textHint), ), const SizedBox(height: 4), Text( value, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), const SizedBox(height: 2), Text( unit, style: const TextStyle(fontSize: 11, color: AppColors.textHint), ), ], ), ); } } class _StatusBadge extends StatelessWidget { final String label; final bool abnormal; const _StatusBadge({required this.label, required this.abnormal}); @override Widget build(BuildContext context) { final color = abnormal ? AppColors.warningText : AppColors.successText; final background = abnormal ? AppColors.warningLight : AppColors.successLight; return Container( constraints: const BoxConstraints(maxWidth: 78), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: background, borderRadius: AppRadius.pillBorder, border: Border.all(color: color.withValues(alpha: 0.18)), ), child: FittedBox( fit: BoxFit.scaleDown, child: Text( label, maxLines: 1, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: color, height: 1, ), ), ), ); } } class _ChartLegend extends StatelessWidget { final Color color; final String label; const _ChartLegend({required this.color, required this.label}); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 14, height: 3, decoration: BoxDecoration( color: color, borderRadius: AppRadius.pillBorder, ), ), const SizedBox(width: 5), Text( label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), ), ], ); } }