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_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/app_toast.dart'; import '../../providers/data_providers.dart'; /// 健康概览趋势页 — 五大指标合到一个页面 class TrendPage extends ConsumerStatefulWidget { final String? metricType; // 可选初始选中指标 const TrendPage({super.key, this.metricType}); @override ConsumerState createState() => _TrendPageState(); } class _TrendPageState extends ConsumerState { String _selected = 'blood_pressure'; List> _allRecords = []; List> _filtered = []; bool _loading = true; 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': Color(0xFFEF4444)}, {'key': 'heart_rate', 'label': '心率', 'color': Color(0xFFF59E0B)}, {'key': 'glucose', 'label': '血糖', 'color': Color(0xFF4F6EF7)}, {'key': 'spo2', 'label': '血氧', 'color': Color(0xFF20C997)}, {'key': 'weight', 'label': '体重', 'color': Color(0xFF845EF7)}, ]; 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'; Color get _color => _metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color; @override void initState() { super.initState(); if (widget.metricType != null) _selected = widget.metricType!; _loadAll(); } Future _loadAll() async { setState(() => _loading = true); try { final api = ref.read(apiClientProvider); final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2', 'Weight']; final all = >[]; for (final t in types) { try { final res = await api.get( '/api/health-records/trend', queryParameters: {'type': t, 'period': 365}, ); final list = (res.data['data'] as List?)?.cast>() ?? []; for (final r in list) { all.add({ 'id': r['id'], 'type': t, 'date': DateTime.tryParse( r['recordedAt']?.toString() ?? '', )?.toLocal() ?? DateTime.now(), 'systolic': r['systolic'], 'diastolic': r['diastolic'], 'value': r['value'], 'unit': r['unit'] ?? '', 'isAbnormal': r['isAbnormal'] == true, }); } } catch (e) { debugPrint('[Trend] 加载趋势失败: $e'); } } all.sort( (a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime), ); if (mounted) { setState(() { _allRecords = all; _loading = false; }); _filter(); } } catch (_) { if (mounted) setState(() => _loading = false); } } void _filter() { final typeName = _selected == 'blood_pressure' ? 'BloodPressure' : _selected == 'heart_rate' ? 'HeartRate' : _selected == 'glucose' ? 'Glucose' : _selected == 'spo2' ? 'SpO2' : 'Weight'; setState(() { _filtered = _allRecords.where((r) => r['type'] == typeName).toList(); _filtered.sort( (a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime), ); }); } void _switchMetric(String key) { setState(() { _selected = key; _selectedIdx = null; }); _filter(); } // ---- 手动录入 ---- 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: ElevatedButton( onPressed: () async { final v1 = double.tryParse(ctrl1.text.trim()); if (v1 == null) return; try { final api = ref.read(apiClientProvider); final body = { 'type': _selected == 'blood_pressure' ? 'BloodPressure' : _selected == 'heart_rate' ? 'HeartRate' : _selected == 'glucose' ? 'Glucose' : _selected == 'spo2' ? 'SpO2' : 'Weight', 'source': 'Manual', 'recordedAt': DateTime.now().toUtc().toIso8601String(), 'unit': _unit, }; if (_isBP) { body['systolic'] = v1.toInt(); body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0; } 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); } }, style: ElevatedButton.styleFrom( backgroundColor: _color, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.symmetric(vertical: 14), ), child: const Text('确认录入', style: TextStyle(fontSize: 19)), ), ), 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: FloatingActionButton( onPressed: _showAddDialog, backgroundColor: _color, child: const Icon(Icons.add), ), 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), _buildChart(), const SizedBox(height: 20), _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 ? color : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: sel ? color : AppColors.border), ), child: Text( m['label'] as String, style: TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: sel ? Colors.white : AppColors.textSecondary, ), ), ), ); }).toList(), ), ); } // ---- 趋势图 ---- Widget _buildChart() { if (_filtered.isEmpty) { return Container( height: 220, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), boxShadow: AppColors.cardShadowLight, ), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.show_chart, size: 48, color: AppColors.textHint.withValues(alpha: 0.45), ), const SizedBox(height: 8), Text( '暂无$_name数据', style: const TextStyle(color: AppColors.textHint), ), const SizedBox(height: 4), const Text( '点击右下角 + 手动录入', style: TextStyle(fontSize: 15, color: AppColors.textHint), ), ], ), ), ); } final spots = []; double minV = double.infinity, maxV = double.negativeInfinity; for (int i = 0; i < _filtered.length; i++) { final r = _filtered[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 (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: BorderRadius.circular(16), border: Border.all(color: AppColors.border), boxShadow: AppColors.cardShadowLight, ), 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( '趋势 (${_filtered.length}条)', style: const TextStyle( fontSize: 15, color: AppColors.textSecondary, ), ), const Spacer(), Text( '单位: $_unit', style: const TextStyle(fontSize: 14, color: AppColors.textHint), ), ], ), 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! < _filtered.length) { final r = _filtered[_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: (spots.length - 1).toDouble(), minY: minV, maxY: maxV, gridData: const FlGridData(show: false), 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 >= _filtered.length) { return const SizedBox.shrink(); } final d = _filtered[idx]['date'] as DateTime; // 当前可见刻度与上一个可见刻度相比,月份是否变化 final prevIdx = idx - xInterval.toInt(); final showMonth = idx == 0 || prevIdx < 0 || (_filtered[prevIdx]['date'] as DateTime).month != d.month; return Column( mainAxisSize: MainAxisSize.min, children: [ Text( '${d.day}', style: const TextStyle( fontSize: 12, color: AppColors.textHint, fontWeight: FontWeight.w500, ), ), if (showMonth) ...[ const SizedBox(height: 2), Text( '${d.month}月', style: const TextStyle( fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w600, ), ), ], ], ); }, ), ), 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: true, gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ _color.withValues(alpha: 0.22), _color.withValues(alpha: 0.02), ], ), ), shadow: Shadow( color: _color.withValues(alpha: 0.18), blurRadius: 6, offset: const Offset(0, 3), ), ), ], 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: BorderRadius.circular(8), 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, ), ), ], ), ), ), ), ], ); }, ), ), ], ), ); } // ---- 历史记录列表 ---- 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), ..._filtered.reversed.take(30).map((r) { final date = r['date'] as DateTime; final abnormal = r['isAbnormal'] == true; final id = r['id']?.toString() ?? ''; final idx = _filtered.indexOf(r); final isSelected = idx == _selectedIdx; String display; if (_isBP) { display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}'; } else { display = '${r['value'] ?? '--'}'; } return Dismissible( key: Key(id), direction: DismissDirection.endToStart, background: Container( margin: const EdgeInsets.only(bottom: 6), decoration: BoxDecoration( color: AppColors.error, borderRadius: BorderRadius.circular(12), ), 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'); setState(() { _allRecords.removeWhere((x) => x['id']?.toString() == id); _filtered.removeWhere((x) => x['id']?.toString() == id); _selectedIdx = null; }); return true; } catch (_) { return false; } }, child: GestureDetector( onTap: () { if (idx < 0) return; setState(() => _selectedIdx = isSelected ? null : idx); }, child: AnimatedContainer( duration: const Duration(milliseconds: 180), margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: isSelected ? _color.withValues(alpha: 0.15) : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? _color.withValues(alpha: 0.55) : AppColors.border, width: isSelected ? 1.5 : 1, ), boxShadow: AppColors.cardShadowLight, ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10), ), child: Center( child: Icon( _getMetricIcon(_selected), size: 21, color: _color, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}', style: const TextStyle( fontSize: 15, color: AppColors.textHint, ), ), const SizedBox(height: 2), Text( '$display $_unit', style: TextStyle( fontSize: 23, fontWeight: FontWeight.w700, color: abnormal ? AppColors.error : AppColors.textPrimary, ), ), ], ), ), if (abnormal) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( color: AppColors.errorLight, borderRadius: BorderRadius.circular(6), ), child: const Text( '异常', style: TextStyle( fontSize: 14, color: AppColors.error, fontWeight: FontWeight.w500, ), ), ), ], ), ), ), ); }), ], ); } IconData _getMetricIcon(String key) { switch (key) { case 'blood_pressure': return Icons.monitor_heart_outlined; case 'heart_rate': return Icons.favorite_border; case 'glucose': return Icons.water_drop_outlined; case 'spo2': return Icons.air_outlined; case 'weight': return Icons.monitor_weight_outlined; default: return Icons.show_chart; } } }