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 '../../providers/auth_provider.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; 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)}, ]; static const _units = { 'blood_pressure': 'mmHg', 'heart_rate': 'bpm', 'glucose': 'mmol/L', 'spo2': '%', }; static const _names = { 'blood_pressure': '血压', 'heart_rate': '心率', 'glucose': '血糖', 'spo2': '血氧', }; 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']; 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() ?? '') ?? 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' : 'SpO2'; 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); _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' : 'SpO2', '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; } ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('录入失败'))); } }, 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(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; } 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: LineChart( LineChartData( minX: 0, maxX: (spots.length - 1).toDouble(), minY: minV, maxY: maxV, gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: range > 50 ? 10 : range > 20 ? 5 : range > 5 ? 2 : 1, ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (v, meta) => Text( v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1), style: const TextStyle( fontSize: 13, color: AppColors.textHint, ), ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 24, interval: spots.length > 30 ? 7 : spots.length > 14 ? 3 : 1, getTitlesWidget: (v, meta) { final idx = v.toInt(); if (idx < 0 || idx >= _filtered.length) { return const SizedBox.shrink(); } final d = _filtered[idx]['date'] as DateTime; return Text( '${d.month}/${d.day}', style: const TextStyle( fontSize: 12, color: AppColors.textHint, ), ); }, ), ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), ), lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, color: _color, barWidth: 2.5, dotData: FlDotData( show: spots.length <= 60, getDotPainter: (spot, _, _, _) => FlDotCirclePainter( radius: 3, color: _color, strokeWidth: 1, strokeColor: Colors.white, ), ), belowBarData: BarAreaData( show: true, color: _color.withAlpha(20), ), ), ], lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipItems: (spots) => spots.map((s) { final idx = s.x.toInt(); if (idx < 0 || idx >= _filtered.length) return null; final r = _filtered[idx]; final d = r['date'] as DateTime; final v = _isBP ? '${r['systolic']}/${r['diastolic']}' : '${r['value']}'; return LineTooltipItem( '${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit', TextStyle( color: _color, fontSize: 15, fontWeight: FontWeight.w600, ), ); }).toList(), ), ), ), ), ), ], ), ); } // ---- 历史记录列表 ---- 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() ?? ''; 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); }); return true; } catch (_) { return false; } }, child: Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), boxShadow: AppColors.cardShadowLight, ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10), ), child: Center( child: Text( _getMetricIcon(_selected), style: const TextStyle(fontSize: 21), ), ), ), 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, ), ), ), ], ), ), ); }), ], ); } String _getMetricIcon(String key) { switch (key) { case 'blood_pressure': return '🫀'; case 'heart_rate': return '💓'; case 'glucose': return '🩸'; case 'spo2': return '🫁'; default: return '📊'; } } }