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)}, {'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() ?? '') ?? 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); _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); Navigator.pop(ctx); ref.invalidate(latestHealthProvider); _loadAll(); } catch (_) { 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 : const Color(0xFFE0E0E0)), ), child: Text(m['label'] as String, style: TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: sel ? Colors.white : const Color(0xFF666666)), ), ), ); }).toList()), ); } // ---- 趋势图 ---- Widget _buildChart() { if (_filtered.isEmpty) { return Container( height: 220, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.show_chart, size: 48, color: Colors.grey[300]), const SizedBox(height: 8), Text('暂无$_name数据', style: const TextStyle(color: Color(0xFFBBBBBB))), const SizedBox(height: 4), const Text('点击右下角 + 手动录入', style: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC))), ])), ); } 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)), 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: Color(0xFF999999))), const Spacer(), Text('单位: $_unit', style: const TextStyle(fontSize: 14, color: Color(0xFFBBBBBB))), ]), 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: Color(0xFF999999))), )), 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: Color(0xFFBBBBBB))); }, )), 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: Color(0xFF999999))), ]), 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)), 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: Color(0xFF999999))), const SizedBox(height: 2), Text('$display $_unit', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700, color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))), ])), if (abnormal) Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)), child: const Text('异常', style: TextStyle(fontSize: 14, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))), ]), ), ); }), ]); } String _getMetricIcon(String key) { switch (key) { case 'blood_pressure': return '🫀'; case 'heart_rate': return '💓'; case 'glucose': return '🩸'; case 'spo2': return '🫁'; case 'weight': return '⚖️'; default: return '📊'; } } }