diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index fa64f0c..3fbdb26 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -1,87 +1,833 @@ +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/navigation_provider.dart'; class TrendPage extends ConsumerStatefulWidget { final String metricType; const TrendPage({super.key, required this.metricType}); - @override ConsumerState createState() => _TrendPageState(); + + @override + ConsumerState createState() => _TrendPageState(); } class _TrendPageState extends ConsumerState { int _period = 7; + bool _showAllRecords = false; + late List> _data; + + static const _labels = { + 'blood_pressure': '血压趋势', + 'heart_rate': '心率趋势', + 'glucose': '血糖趋势', + 'spo2': '血氧趋势', + 'weight': '体重趋势', + }; + + static const _units = { + 'blood_pressure': 'mmHg', + 'heart_rate': 'bpm', + 'glucose': 'mmol/L', + 'spo2': '%', + 'weight': 'kg', + }; + + static const _normalRanges = { + 'blood_pressure': {'low1': 90, 'high1': 140, 'low2': 60, 'high2': 90}, + 'heart_rate': {'low': 60, 'high': 100}, + 'glucose': {'low': 3.9, 'high': 6.1}, + 'spo2': {'low': 95.0, 'high': 100.0}, + 'weight': {'low': 18.5, 'high': 24.9}, // BMI range + }; + + @override + void initState() { + super.initState(); + _data = _generateMockData(); + } + + @override + void didUpdateWidget(covariant TrendPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.metricType != widget.metricType) { + setState(() => _data = _generateMockData()); + } + } + + String _getUnit() => _units[widget.metricType] ?? ''; + bool get _isDualLine => widget.metricType == 'blood_pressure'; + + String _getStatus(num value, {num? value2}) { + if (widget.metricType == 'blood_pressure') { + if (value > (_normalRanges['blood_pressure']!['high1'] as num) || + (value2 ?? 0) > (_normalRanges['blood_pressure']!['high2'] as num)) { + return '偏高'; + } + if (value < (_normalRanges['blood_pressure']!['low1'] as num) || + (value2 ?? 0) < (_normalRanges['blood_pressure']!['low2'] as num)) { + return '偏低'; + } + return '正常'; + } + final range = _normalRanges[widget.metricType]; + if (range != null && range.containsKey('low')) { + if (value > (range['high'] as num)) return '偏高'; + if (value < (range['low'] as num)) return '偏低'; + } + return '正常'; + } + + Color _getStatusColor(String status) { + switch (status) { + case '偏高': + return const Color(0xFFE53935); + case '偏低': + return const Color(0xFFFF9800); + default: + return const Color(0xFF43A047); + } + } + + List> _generateMockData() { + final now = DateTime.now(); + final data = >[]; + final rng = Random(42); + + for (int i = _period - 1; i >= 0; i--) { + final date = now.subtract(Duration(days: i)); + num value; + num? value2; + + switch (widget.metricType) { + case 'blood_pressure': + value = 110 + rng.nextInt(40); + value2 = 70 + rng.nextInt(25); + break; + case 'heart_rate': + value = 65 + rng.nextInt(35); + break; + case 'glucose': + value = 4.0 + rng.nextDouble() * 3.0; + break; + case 'spo2': + value = 95 + rng.nextDouble() * 5; + break; + case 'weight': + value = 58 + rng.nextDouble() * 15; + break; + default: + value = 100 + rng.nextInt(50); + } + + data.add({ + 'date': date, + 'value': value, + if (value2 != null) ...{'value2': value2}, + }); + } + return data; + } + + String _formatDateLabel(DateTime date) { + switch (_period) { + case 7: + const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + return weekdays[date.weekday - 1]; + case 30: + return '${date.month}/${date.day}'; + default: // 90 + return '${date.month}月'; + } + } + + String _formatDateTime(DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt).inDays; + if (diff == 0) return '今天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + if (diff == 1) return '昨天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + String _formatValue(num v) { + if (v is double && widget.metricType != 'weight' && widget.metricType != 'spo2') { + return v.toStringAsFixed(1); + } + if (widget.metricType == 'spo2') { + return v.toStringAsFixed(1); + } + if (widget.metricType == 'weight') { + return v.toStringAsFixed(1); + } + return v.toInt().toString(); + } + + // ---- Y轴范围计算 ---- + ({double min, double max, double step}) _calcYRange() { + if (_data.isEmpty) return (min: 0, max: 100, step: 20); + + num allMin, allMax; + if (_isDualLine) { + final values1 = _data.map((e) => e['value'] as num); + final values2 = _data.map((e) => e['value2'] as num); + allMin = min(values1.reduce(min), values2.reduce(min)); + allMax = max(values1.reduce(max), values2.reduce(max)); + } else { + final values = _data.map((e) => e['value'] as num); + allMin = values.reduce(min); + allMax = values.reduce(max); + } + + final padding = (allMax - allMin) * 0.15; + if (padding == 0) { + allMin -= 10; + allMax += 10; + } else { + allMin -= padding; + allMax += padding; + } + + var rawMin = allMin.toDouble(); + var rawMax = allMax.toDouble(); + + final range = rawMax - rawMin; + double step; + if (range <= 1) { + step = 0.2; + } else if (range <= 10) { + step = 2; + } else if (range <= 50) { + step = 10; + } else if (range <= 200) { + step = 20; + } else { + step = 50; + } + + final niceMin = (rawMin / step).floor() * step; + final niceMax = (rawMax / step).ceil() * step; + return (min: niceMin, max: niceMax, step: step); + } + + Map _calcStats() { + if (_data.isEmpty) return {}; + final values = _data.map((e) => e['value'] as num).toList(); + + final maxVal = values.reduce(max); + final minVal = values.reduce(min); + final avgVal = values.reduce((a, b) => a + b) / values.length; + + String avgStr; + if (_isDualLine) { + final values2 = _data.map((e) => e['value2'] as num).toList(); + final maxVal2 = values2.reduce(max); + final minVal2 = values2.reduce(min); + final avgVal2 = values2.reduce((a, b) => a + b) / values2.length; + avgStr = '${_formatValue(avgVal)} / ${_formatValue(avgVal2)}'; + return { + 'max': '${_formatValue(maxVal)} / ${_formatValue(maxVal2)}', + 'min': '${_formatValue(minVal)} / ${_formatValue(minVal2)}', + 'avg': avgStr, + 'count': _data.length, + }; + } + + return { + 'max': _formatValue(maxVal), + 'min': _formatValue(minVal), + 'avg': _formatValue(avgVal), + 'count': _data.length, + }; + } + + @override + Widget build(BuildContext context) { + final title = _labels[widget.metricType] ?? '趋势图表'; - @override Widget build(BuildContext context) { - final labels = {'blood_pressure': '血压趋势', 'heart_rate': '心率趋势', 'glucose': '血糖趋势', 'spo2': '血氧趋势', 'weight': '体重趋势'}; return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: Text(labels[widget.metricType] ?? '趋势图表', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), centerTitle: true), - body: Column(children: [ - Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - _TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() => _period = 7)), - const SizedBox(width: 12), _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() => _period = 30)), - const SizedBox(width: 12), _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() => _period = 90)), - ])), - Container(margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: const Color(0xFFF8F9FF), borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFFE8E6FF))), child: Column(children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text(widget.metricType == 'blood_pressure' ? '血压趋势' : labels[widget.metricType] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), Row(children: [Container(width: 10, height: 10, decoration: BoxDecoration(color: const Color(0xFF635BFF), shape: BoxShape.circle)), const SizedBox(width: 4), Text('收缩压', style: TextStyle(fontSize: 12, color: Colors.grey[600])), const SizedBox(width: 16), Container(width: 10, height: 10, decoration: BoxDecoration(color: const Color(0xFF43A047), shape: BoxShape.circle)), const SizedBox(width: 4), Text('舒张压', style: TextStyle(fontSize: 12, color: Colors.grey[600]))])]), - const SizedBox(height: 24), - SizedBox(height: 200, child: CustomPaint(painter: _LineChartPainter(period: _period), size: Size.infinite)), - ])), - if (widget.metricType == 'blood_pressure') Container(margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFEEEEEE))), child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [const _StatItem(label: '最高', value: '145', unit: '', color: Color(0xFFE53935)), const _StatItem(label: '最低', value: '78', unit: '', color: Color(0xFF43A047)), const _StatItem(label: '平均', value: '120', unit: '/80', color: Color(0xFF635BFF))])), + backgroundColor: const Color(0xFFF8F7FF), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () => popRoute(ref), + ), + title: Text( + title, + style: const TextStyle( + color: Color(0xFF1A1A1A), + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + centerTitle: true, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + + // ---- 时间段选择器 ---- + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() { _period = 7; _showAllRecords = false; _data = _generateMockData(); })), + const SizedBox(width: 12), + _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() { _period = 30; _showAllRecords = false; _data = _generateMockData(); })), + const SizedBox(width: 12), + _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() { _period = 90; _showAllRecords = false; _data = _generateMockData(); })), + ], + ), + ), + const SizedBox(height: 16), + + // ---- 当前值卡片 ---- + ..._buildCurrentValueCard(), + const SizedBox(height: 16), + + // ---- 趋势图表区域 ---- + _buildChartArea(), + const SizedBox(height: 16), + + // ---- 统计摘要行 ---- + _buildStatSummary(), + const SizedBox(height: 16), + + // ---- 数据记录列表 ---- + _buildRecordList(), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + // ==================== 当前值卡片 ==================== + List _buildCurrentValueCard() { + if (_data.isEmpty) return []; + final latest = _data.last; + final val = latest['value']; + final val2 = latest['value2']; + final status = _getStatus(val as num, value2: val2 as num?); + + String displayValue; + if (_isDualLine) { + displayValue = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}'; + } else { + displayValue = '${_formatValue(val)} ${_getUnit()}'; + } + + final timeStr = _formatDateTime(latest['date'] as DateTime); + + return [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFEDEBFF), Color(0xFFF3F1FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayValue, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 6), + Text( + timeStr, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF888888), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: _getStatusColor(status).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + status, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _getStatusColor(status), + ), + ), + ), + ], + ), + ), + ]; + } + + // ==================== 图表区域 ==================== + Widget _buildChartArea() { + if (_data.isEmpty) { + return Container( + height: 220, + alignment: Alignment.center, + child: const Text('暂无数据', style: TextStyle(color: Color(0xFF999999), fontSize: 14)), + ); + } + + final yRange = _calcYRange(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 16, 8, 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF635BFF).withValues(alpha: 0.06), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + // 图例 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLegendDot(const Color(0xFF635BFF), _isDualLine ? '收缩压' : _labels[widget.metricType]?.replaceAll('趋势', '') ?? ''), + if (_isDualLine) ...[ + const SizedBox(width: 24), + _buildLegendDot(const Color(0xFF43A047), '舒张压'), + ], + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 200, + child: CustomPaint( + painter: _TrendChartPainter( + data: _data, + metricType: widget.metricType, + isDualLine: _isDualLine, + yMin: yRange.min, + yMax: yRange.max, + yStep: yRange.step, + formatDateLabel: _formatDateLabel, + formatValue: _formatValue, + ), + size: Size.infinite, + ), + ), + ], + ), + ); + } + + Widget _buildLegendDot(Color color, String label) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), + ]); + } + + // ==================== 统计摘要 ==================== + Widget _buildStatSummary() { + final stats = _calcStats(); + if (stats.isEmpty) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + child: Row(children: [ + Expanded(child: _StatItem(label: '最高值', value: stats['max'].toString(), color: const Color(0xFFE53935))), + _buildVerticalDivider(), + Expanded(child: _StatItem(label: '最低值', value: stats['min'].toString(), color: const Color(0xFFFF9800))), + _buildVerticalDivider(), + Expanded(child: _StatItem(label: '平均值', value: stats['avg'].toString(), color: const Color(0xFF635BFF))), + _buildVerticalDivider(), + Expanded(child: _StatItem(label: '测量次数', value: stats['count'].toString(), color: const Color(0xFF78909C))), ]), ); } + + Widget _buildVerticalDivider() => Container(width: 1, height: 36, color: const Color(0xFFF0F0F0)); + + // ==================== 数据记录列表 ==================== + Widget _buildRecordList() { + if (_data.isEmpty) return const SizedBox.shrink(); + + final displayList = _showAllRecords ? _data : _data.reversed.take(5).toList().reversed.toList(); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('数据记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + Text('${_data.length} 条', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), + ], + ), + ), + ...displayList.map((item) => _buildRecordRow(item)), + if (_data.length > 5) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: TextButton( + onPressed: () => setState(() => _showAllRecords = !_showAllRecords), + style: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + ), + child: Text( + _showAllRecords ? '收起' : '查看全部 (${_data.length} 条)', + style: const TextStyle(fontSize: 13, color: Color(0xFF635BFF)), + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + Widget _buildRecordRow(Map item) { + final date = item['date'] as DateTime; + final val = item['value'] as num; + final val2 = item['value2'] as num?; + final status = _getStatus(val, value2: val2); + + String displayVal; + if (_isDualLine) { + displayVal = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}'; + } else { + displayVal = '${_formatValue(val)} ${_getUnit()}'; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text(_formatDateTime(date), style: const TextStyle(fontSize: 13, color: Color(0xFF666666))), + ), + Expanded( + child: Text(displayVal, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), + ), + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: _getStatusColor(status), shape: BoxShape.circle), + ), + ], + ), + ); + } } +// ============================================================ +// 子组件 +// ============================================================ + class _TimeChip extends StatelessWidget { - final String label; final bool selected; final VoidCallback onTap; + final String label; + final bool selected; + final VoidCallback onTap; const _TimeChip({required this.label, required this.selected, required this.onTap}); - @override Widget build(BuildContext context) => GestureDetector( + @override + Widget build(BuildContext context) => GestureDetector( onTap: onTap, - child: Container(padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8), decoration: BoxDecoration(color: selected ? const Color(0xFF635BFF) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: selected ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0))), child: Text(label, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, color: selected ? Colors.white : const Color(0xFF757575)))), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8), + decoration: BoxDecoration( + color: selected ? const Color(0xFF635BFF) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: selected ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)), + boxShadow: selected ? [BoxShadow(color: const Color(0xFF635BFF).withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 3))] : null, + ), + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? Colors.white : const Color(0xFF757575), + ), + ), + ), ); } -class _StatItem extends StatelessWidget { final String label; final String value; final String unit; final Color color; - const _StatItem({required this.label, required this.value, required this.unit, required this.color}); - @override Widget build(BuildContext context) => Column(children: [Text(value + unit, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)), const SizedBox(height: 4), Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[500]))]); +class _StatItem extends StatelessWidget { + final String label; + final String value; + final Color color; + const _StatItem({required this.label, required this.value, required this.color}); + + @override + Widget build(BuildContext context) => Column( + children: [ + Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 6), + Flexible(child: Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: color), overflow: TextOverflow.ellipsis)), + ]), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), + ], + ); } -class _LineChartPainter extends CustomPainter { - final int period; - _LineChartPainter({required this.period}); +// ============================================================ +// 趋势图 CustomPainter +// ============================================================ - @override void paint(Canvas canvas, Size size) { - final paint = Paint()..color = const Color(0xFF635BFF)..strokeWidth = 2..style = PaintingStyle.stroke; - final paint2 = Paint()..color = const Color(0xFF43A047)..strokeWidth = 2..style = PaintingStyle.stroke; - final fillPaint1 = Paint()..color = const Color(0xFF635BFF)..style = PaintingStyle.fill; - final fillPaint2 = Paint()..color = const Color(0xFF43A047)..style = PaintingStyle.fill; - final whitePaint = Paint()..color = Colors.white..style = PaintingStyle.fill; +class _TrendChartPainter extends CustomPainter { + final List> data; + final String metricType; + final bool isDualLine; + final double yMin; + final double yMax; + final double yStep; + final String Function(DateTime) formatDateLabel; + final String Function(num) formatValue; - final points1 = []; + static const _primaryColor = Color(0xFF635BFF); + static const _secondaryColor = Color(0xFF43A047); + static const _gridColor = Color(0xFFEEEEEE); + static const _labelColor = Color(0xFF999999); + + _TrendChartPainter({ + required this.data, + required this.metricType, + required this.isDualLine, + required this.yMin, + required this.yMax, + required this.yStep, + required this.formatDateLabel, + required this.formatValue, + }); + + @override + void paint(Canvas canvas, Size size) { + if (data.length < 2) return; + + final leftPadding = 44.0; + final rightPadding = 8.0; + final topPadding = 8.0; + final bottomPadding = 28.0; + final chartW = size.width - leftPadding - rightPadding; + final chartH = size.height - topPadding - bottomPadding; + final plotLeft = leftPadding; + final plotTop = topPadding; + final plotRight = size.width - rightPadding; + final plotBottom = size.height - bottomPadding; + + // ---- 1. 网格线 & Y轴刻度 ---- + _drawGridAndYAxis(canvas, plotLeft, plotTop, plotRight, plotBottom, chartH); + + // ---- 2. 数据点坐标计算 ---- + final points1 = []; final points2 = []; - - if (period <= 1) return; - - for (int i = 0; i < period; i++) { - final x = size.width * i / (period - 1); - points1.add(Offset(x, size.height * 0.3 + (i % 3) * 15)); - points2.add(Offset(x, size.height * 0.6 + (i % 4) * 10)); + for (int i = 0; i < data.length; i++) { + final x = plotLeft + (chartW * i / (data.length - 1)); + final val = (data[i]['value'] as num).toDouble(); + final y = plotTop + chartH - ((val - yMin) / (yMax - yMin)) * chartH; + points1.add(Offset(x, y.clamp(plotTop, plotBottom))); + + if (isDualLine && data[i].containsKey('value2')) { + final val2 = (data[i]['value2'] as num).toDouble(); + final y2 = plotTop + chartH - ((val2 - yMin) / (yMax - yMin)) * chartH; + points2.add(Offset(x, y2.clamp(plotTop, plotBottom))); + } } - if (points1.length > 1) { - final path1 = Path()..moveTo(points1[0].dx, points1[0].dy); - for (var p in points1.skip(1)) path1.lineTo(p.dx, p.dy); - canvas.drawPath(path1, paint); + // ---- 3. 填充区域(主线) ---- + _drawFill(canvas, points1, plotBottom, _primaryColor); - final path2 = Path()..moveTo(points2[0].dx, points2[0].dy); - for (var p in points2.skip(1)) path2.lineTo(p.dx, p.dy); - canvas.drawPath(path2, paint2); + // ---- 4. 填充区域(副线,血压) ---- + if (isDualLine && points2.isNotEmpty) { + _drawFill(canvas, points2, plotBottom, _secondaryColor); } - for (var p in points1) { canvas.drawCircle(p, 4, whitePaint); canvas.drawCircle(p, 3, fillPaint1); } - for (var p in points2) { canvas.drawCircle(p, 4, whitePaint); canvas.drawCircle(p, 3, fillPaint2); } + // ---- 5. 数据线(主线) ---- + _drawSmoothLine(canvas, points1, _primaryColor); + + // ---- 6. 数据线(副线) ---- + if (isDualLine && points2.isNotEmpty) { + _drawSmoothLine(canvas, points2, _secondaryColor); + } + + // ---- 7. 数据点 ---- + _drawPoints(canvas, points1, _primaryColor); + if (isDualLine && points2.isNotEmpty) { + _drawPoints(canvas, points2, _secondaryColor); + } + + // ---- 8. X轴日期标签 ---- + _drawXLabels(canvas, plotLeft, plotRight, plotBottom, chartW); } - @override bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate is! _LineChartPainter || oldDelegate.period != period; + void _drawGridAndYAxis(Canvas canvas, double plotLeft, double plotTop, double plotRight, double plotBottom, double chartH) { + final dashPaint = Paint() + ..color = _gridColor + ..strokeWidth = 0.8 + ..style = PaintingStyle.stroke; + + final labelStyle = TextStyle(color: _labelColor, fontSize: 11); + + final stepsCount = ((yMax - yMin) / yStep).round(); + for (int i = 0; i <= stepsCount; i++) { + final yVal = yMin + yStep * i; + if (yVal > yMax + 0.001) break; + final y = plotTop + chartH - ((yVal - yMin) / (yMax - yMin)) * chartH; + + // 虚线网格 + final dashPath = Path(); + const dashLen = 4.0; + const gapLen = 3.0; + var startX = plotLeft; + while (startX < plotRight) { + final endX = (startX + dashLen).clamp(startX, plotRight); + dashPath.moveTo(startX, y); + dashPath.lineTo(endX, y); + startX = endX + gapLen; + } + canvas.drawPath(dashPath, dashPaint); + + // Y轴标签 + final textSpan = TextSpan(text: formatValue(yVal), style: labelStyle); + final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout(); + tp.paint(canvas, Offset(plotLeft - tp.width - 6, y - tp.height / 2)); + } + } + + void _drawFill(Canvas canvas, List points, double plotBottom, Color lineColor) { + if (points.length < 2) return; + final fillPaint = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [lineColor.withValues(alpha: 0.25), lineColor.withValues(alpha: 0.02)], + ).createShader(Rect.fromLTWH(0, 0, points.last.dx - points.first.dx, plotBottom - points.first.dy)) + ..style = PaintingStyle.fill; + + final fillPath = Path() + ..moveTo(points.first.dx, plotBottom) + ..lineTo(points.first.dx, points.first.dy); + for (int i = 1; i < points.length; i++) { + final midX = (points[i - 1].dx + points[i].dx) / 2; + fillPath.quadraticBezierTo(midX, points[i - 1].dy, midX, (points[i - 1].dy + points[i].dy) / 2); + } + fillPath.lineTo(points.last.dx, points.last.dy); + fillPath.lineTo(points.last.dx, plotBottom); + fillPath.close(); + canvas.drawPath(fillPath, fillPaint); + } + + void _drawSmoothLine(Canvas canvas, List points, Color color) { + if (points.length < 2) return; + final paint = Paint() + ..color = color + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + final path = Path()..moveTo(points[0].dx, points[0].dy); + for (int i = 1; i < points.length; i++) { + final cpx = (points[i - 1].dx + points[i].dx) / 2; + path.quadraticBezierTo(cpx, points[i - 1].dy, (cpx + points[i].dx) / 2, (points[i - 1].dy + points[i].dy) / 2); + } + path.lineTo(points.last.dx, points.last.dy); + canvas.drawPath(path, paint); + } + + void _drawPoints(Canvas canvas, List points, Color color) { + final whitePaint = Paint()..color = Colors.white..style = PaintingStyle.fill; + final strokePaint = Paint() + ..color = color + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + for (final p in points) { + canvas.drawCircle(p, 5, whitePaint); + canvas.drawCircle(p, 4, strokePaint); + } + } + + void _drawXLabels(Canvas canvas, double plotLeft, double plotRight, double plotBottom, double chartW) { + final labelStyle = TextStyle(color: _labelColor, fontSize: 11); + final count = data.length; + final maxLabels = count <= 7 ? count : (count ~/ 7).clamp(3, 12); + + final step = (count - 1) / (maxLabels - 1); + for (int i = 0; i < maxLabels; i++) { + final idx = (i * step).round().clamp(0, count - 1); + final x = plotLeft + (chartW * idx / (count - 1)); + final date = data[idx]['date'] as DateTime; + final textSpan = TextSpan(text: formatDateLabel(date), style: labelStyle); + final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout(); + tp.paint(canvas, Offset(x - tp.width / 2, plotBottom + 6)); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + if (oldDelegate is! _TrendChartPainter) return true; + return oldDelegate.data != data || + oldDelegate.metricType != metricType || + oldDelegate.isDualLine != isDualLine || + (oldDelegate.yMin - yMin).abs() > 0.001 || + (oldDelegate.yMax - yMax).abs() > 0.001 || + (oldDelegate.yStep - yStep).abs() > 0.001; + } } diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index a16b572..0b7cd46 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -31,7 +31,7 @@ class ChatMessagesView extends ConsumerWidget { const SizedBox(height: 16), Text('开始和 AI 健康管家对话吧', style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 8), - Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 14, color: Colors.grey[400])), + const Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 14, color: Color(0xFF9E9E9E))), ], ), ); @@ -40,7 +40,7 @@ class ChatMessagesView extends ConsumerWidget { return ListView.builder( controller: scrollCtrl, reverse: true, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[messages.length - 1 - index]; @@ -49,6 +49,8 @@ class ChatMessagesView extends ConsumerWidget { ); } + // ─── 消息分发 ───────────────────────────────────────────── + Widget _buildMessageContent(BuildContext context, ChatMessage msg, ChatState chatState) { final isUser = msg.isUser; @@ -57,6 +59,8 @@ class ChatMessagesView extends ConsumerWidget { } switch (msg.type) { + case MessageType.agentWelcome: + return _buildAgentWelcomeCard(context, msg, chatState.activeAgent); case MessageType.dataConfirm: return _buildDataConfirmCard(context, msg); case MessageType.medicationConfirm: @@ -72,33 +76,894 @@ class ChatMessagesView extends ConsumerWidget { } } + // ═══════════════════════════════════════════════════════════ + // 1. AgentWelcomeCard — 智能体欢迎卡片 + // ═══════════════════════════════════════════════════════════ + + Widget _buildAgentWelcomeCard(BuildContext context, ChatMessage msg, ActiveAgent agent) { + final info = _agentInfo(agent); + final actions = agent.actions; + final screenWidth = MediaQuery.of(context).size.width; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: screenWidth * 0.92), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 16, offset: const Offset(0, 4)), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── 紫色渐变头部 ── + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 24, 16, 20), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF7C73FF), Color(0xFF635BFF), Color(0xFF5241D9)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.white.withAlpha(30), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(info.$1, size: 26, color: Colors.white), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(info.$2, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white)), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withAlpha(25), + borderRadius: BorderRadius.circular(10), + ), + child: Text(info.$3, style: const TextStyle(fontSize: 12, color: Color(0xFFE0DDFF))), + ), + ], + ), + ), + GestureDetector( + onTap: () {}, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.white.withAlpha(20), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 16, color: Color(0xFFE0DDFF)), + ), + ), + ], + ), + ), + + // ── 快捷操作按钮网格 ── + Padding( + padding: const EdgeInsets.fromLTRB(18, 18, 18, 4), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: actions.map((a) => _agentActionBtn(a, screenWidth)).toList(), + ), + ), + + // ── 底部提示 ── + Padding( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 18), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container(width: 24, height: 1, color: const Color(0xFFD0CCED)), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Text('或直接对我说...', style: TextStyle(fontSize: 13, color: Color(0xFF9E94CF))), + ), + Container(width: 24, height: 1, color: const Color(0xFFD0CCED)), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _agentActionBtn(_AgentAction a, double screenWidth) { + return InkWell( + onTap: () {}, + borderRadius: BorderRadius.circular(14), + child: Container( + width: ((screenWidth - 72) / (a.isWide ? 2 : 3)) - 10, + padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFFF7F5FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFEBE8FF), width: 1), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: const Color(0xFFEDEAFF), + borderRadius: BorderRadius.circular(11), + ), + child: Icon(a.icon, size: 20, color: const Color(0xFF635BFF)), + ), + const SizedBox(height: 7), + Text(a.label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF333333))), + ], + ), + ), + ); + } + + // ═══════════════════════════════════════════════════════════ + // 2. DataConfirmCard — 增强版数据确认卡片 + // ═══════════════════════════════════════════════════════════ + + Widget _buildDataConfirmCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final metricType = meta?['type'] as String? ?? ''; + final value = meta?['value'] as String? ?? ''; + final abnormal = meta?['abnormal'] as bool? ?? false; + final recordTime = meta?['recordTime'] as String? ?? ''; + final unit = meta?['unit'] as String? ?? _getMetricUnit(metricType); + final trend = meta?['trend'] as List? ?? [0.6, 0.8, 0.5]; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── 绿色勾选条 ── + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFF4CAF50), Color(0xFF43A047)]), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, size: 18, color: Colors.white), + SizedBox(width: 6), + Text('✓ 数据已记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)), + ], + ), + ), + + Padding( + padding: const EdgeInsets.all(18), + child: Column( + children: [ + // 记录时间 + Align( + alignment: Alignment.centerLeft, + child: Text(recordTime.isNotEmpty ? recordTime : _formatTime(msg.createdAt), style: const TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))), + ), + const SizedBox(height: 14), + + // 主要指标区域 + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: const Color(0xFFF9F8FF), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: const Color(0xFFEDEAFF), + borderRadius: BorderRadius.circular(14), + ), + child: Center(child: Text(_getMetricIcon(metricType), style: const TextStyle(fontSize: 26))), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_getMetricName(metricType), style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), + const SizedBox(height: 4), + RichText( + text: TextSpan( + children: [ + TextSpan(text: value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: abnormal ? const Color(0xFFE53935) : const Color(0xFF1A1A2E))), + TextSpan(text: ' $unit', style: TextStyle(fontSize: 14, color: abnormal ? const Color(0xFFE53970) : const Color(0xFF999999))), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // 异常警告条 + if (abnormal) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFFF3F0), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFFFDAD4), width: 1), + ), + child: const Row( + children: [ + Icon(Icons.warning_amber_rounded, size: 18, color: Color(0xFFE53935)), + SizedBox(width: 8), + Expanded(child: Text('⚠️ 数值偏高,建议关注', style: TextStyle(fontSize: 13, color: Color(0xFFE53935), fontWeight: FontWeight.w500))), + ], + ), + ), + ], + + // 迷你趋势图(最近3次) + const SizedBox(height: 16), + Row( + children: [ + const Text('近期趋势', style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), + const Spacer(), + const Text('最近3次', style: TextStyle(fontSize: 11, color: Color(0xFFCCCCCC))), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 36, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: trend.asMap().entries.map((e) { + final h = (e.value * 32).clamp(6.0, 32.0); + return Padding( + padding: EdgeInsets.only(right: e.key < trend.length - 1 ? 10 : 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 22, + height: h, + decoration: BoxDecoration( + color: e.key == trend.length - 1 ? const Color(0xFF635BFF) : const Color(0xFFD5D0FF), + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(height: 4), + Text('${e.key + 1}', style: const TextStyle(fontSize: 9, color: Color(0xFFBBBBBB))), + ], + ), + ); + }).toList(), + ), + ), + + // 底部操作按钮 + const SizedBox(height: 18), + Row(children: [ + Expanded(child: _cardOutlineBtn('编辑', Icons.edit_outlined)), + const SizedBox(width: 8), + Expanded(child: _cardFilledBtn('确认', Icons.check)), + const SizedBox(width: 8), + Expanded(child: _cardOutlineBtn('查看详情', Icons.trending_up_outlined)), + ]), + ], + ), + ), + ], + ), + ), + ); + } + + // ═══════════════════════════════════════════════════════════ + // 3. MedicationConfirmCard — 增强版用药确认卡片 + // ═══════════════════════════════════════════════════════════ + + Widget _buildMedicationConfirmCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final name = meta?['name'] as String? ?? ''; + final dosage = meta?['dosage'] as String? ?? ''; + final time = meta?['time'] as String? ?? ''; + final frequency = meta?['frequency'] as String? ?? ''; + final remaining = meta?['remaining'] as double? ?? 0.65; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── 药品头部 ── + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(18, 20, 18, 16), + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFFE8F0FE), Color(0xFFF5F3FF)]), + ), + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + borderRadius: BorderRadius.circular(13), + ), + child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))), + if (dosage.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(dosage, style: const TextStyle(fontSize: 13, color: Color(0xFF777777))), + ], + ], + ), + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 6), + child: Column( + children: [ + // 服药时间 & 频率 + _medInfoRow(Icons.schedule_outlined, time.isNotEmpty ? '服药时间:$time' : '待设置'), + if (frequency.isNotEmpty) _medInfoRow(Icons.repeat, '频率:$frequency'), + + // 剩余药量进度条 + const SizedBox(height: 14), + Row( + children: [ + const Text('剩余药量', style: TextStyle(fontSize: 13, color: Color(0xFF666666))), + const Spacer(), + Text('${(remaining * 100).toInt()}%', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: remaining, + minHeight: 8, + backgroundColor: const Color(0xFFEFEDFF), + valueColor: const AlwaysStoppedAnimation(Color(0xFF635BFF)), + ), + ), + + // 操作按钮 + const SizedBox(height: 18), + Row(children: [ + Expanded(child: _cardFilledBtn('确认服药', Icons.check_circle_outline)), + const SizedBox(width: 8), + Expanded(child: _cardOutlineBtn('跳过', Icons.skip_next)), + const SizedBox(width: 8), + Expanded(child: _cardOutlineBtn('设置提醒', Icons.notifications_none_outlined)), + ]), + const SizedBox(height: 8), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _medInfoRow(IconData icon, String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Icon(icon, size: 17, color: const Color(0xFF888888)), + const SizedBox(width: 8), + Text(text, style: const TextStyle(fontSize: 13, color: Color(0xFF444444))), + ], + ), + ); + } + + // ═══════════════════════════════════════════════════════════ + // 4. DietAnalysisCard — 增强版饮食分析卡片 + // ═══════════════════════════════════════════════════════════ + + Widget _buildDietAnalysisCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final foods = meta?['foods'] as List? ?? []; + final totalCalories = meta?['totalCalories'] as int? ?? 0; + final rating = meta?['rating'] as int? ?? 0; + final warnings = meta?['warnings'] as List? ?? []; + final carbs = meta?['carbs'] as double? ?? 50.0; + final protein = meta?['protein'] as double? ?? 20.0; + final fat = meta?['fat'] as double? ?? 30.0; + final advice = meta?['advice'] as String? ?? '饮食均衡,多吃蔬菜水果,减少高油高糖食物摄入。'; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── 头部 ── + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFFFFF8E1), Color(0xFFFFF3E0)]), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('🍽️ ', style: TextStyle(fontSize: 18)), + Text('饮食分析结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))), + ], + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 总热量大号数字 + Center( + child: Column( + children: [ + Text('$totalCalories', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.w800, color: Color(0xFFFF8F00))), + const Text('千卡 (kcal)', style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), + ], + ), + ), + const SizedBox(height: 16), + + // 三大营养素圆环指示 + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _nutrientRing('碳水', carbs, const Color(0xFF42A5F5), const Color(0xFFBBDEFB)), + _nutrientRing('蛋白质', protein, const Color(0xFF66BB6A), const Color(0xFFC8E6C9)), + _nutrientRing('脂肪', fat, const Color(0xFFFFA726), const Color(0xFFFFE0B2)), + ], + ), + const SizedBox(height: 16), + + // 食物列表 + const Text('食物明细', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF333333))), + const SizedBox(height: 10), + ...foods.map((food) { + final f = food as Map? ?? {}; + final fCal = (f['calories'] ?? 0) as num; + final fPct = totalCalories > 0 ? (fCal / totalCalories * 100).clamp(0.0, 100.0) : 0.0; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(f['name'] as String? ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + const Spacer(), + Text('${fCal.toInt()} kcal', style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: fPct / 100, + minHeight: 5, + backgroundColor: const Color(0xFFF0EEFF), + valueColor: const AlwaysStoppedAnimation(Color(0xFFFFB74D)), + ), + ), + ], + ), + ); + }), + + // 健康评分 + const SizedBox(height: 14), + Row( + children: [ + const Text('健康评分', style: TextStyle(fontSize: 13, color: Color(0xFF666666))), + const SizedBox(width: 8), + ...List.generate(5, (i) => Padding( + padding: const EdgeInsets.only(right: 2), + child: Icon(i < rating ? Icons.star : Icons.star_border, size: 18, color: i < rating ? const Color(0xFFFFB800) : const Color(0xFFE0E0E0)), + )), + const Spacer(), + Text('$rating/5', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))), + ], + ), + + // 警告 + if (warnings.isNotEmpty) ...[ + const SizedBox(height: 12), + ...warnings.map((w) => Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFFFFBF0), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFFFE082), width: 0.8), + ), + child: Row( + children: [ + const Text('⚠️ ', style: TextStyle(fontSize: 13)), + Expanded(child: Text(w.toString(), style: const TextStyle(fontSize: 12, color: Color(0xFFE65100)))), + ], + ), + )), + ], + + // AI 建议(可展开) + const SizedBox(height: 14), + _ExpandableAdvice(advice: advice), + const SizedBox(height: 6), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _nutrientRing(String label, double pct, Color fgColor, Color bgColor) { + return Column( + children: [ + SizedBox( + width: 56, + height: 56, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration(shape: BoxShape.circle, color: bgColor), + ), + SizedBox( + width: 56, + height: 56, + child: CircularProgressIndicator( + value: pct.clamp(0.0, 100.0) / 100, + strokeWidth: 5, + backgroundColor: Colors.transparent, + valueColor: AlwaysStoppedAnimation(fgColor), + ), + ), + Text('${pct.toInt()}%', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: fgColor)), + ], + ), + ), + const SizedBox(height: 5), + Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF888888))), + ], + ); + } + + // ═══════════════════════════════════════════════════════════ + // 5. ReportAnalysisCard — 增强版报告分析卡片 + // ═══════════════════════════════════════════════════════════ + + Widget _buildReportAnalysisCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final reportType = meta?['type'] as String? ?? '体检报告'; + final reportDate = meta?['date'] as String? ?? ''; + final indicators = meta?['indicators'] as List? ?? []; + final summary = meta?['summary'] as String? ?? ''; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── 报告头部 ── + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(18, 18, 18, 14), + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFFE8EAF6), Color(0xFFEDE7F6)]), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFC5CAE9), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.description_outlined, size: 20, color: Color(0xFF3F51B5)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(reportType, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))), + if (reportDate.isNotEmpty) + Text(reportDate, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), + ], + ), + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 指标表格 + Container( + decoration: BoxDecoration( + color: const Color(0xFFFAFAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEEEEEE), width: 1), + ), + child: Column( + children: [ + // 表头 + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: const BoxDecoration( + color: Color(0xFFF5F4FA), + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ), + child: const Row( + children: [ + Expanded(flex: 2, child: Text('指标名称', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF666666)))), + Expanded(flex: 1, child: Text('数值', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF666666)), textAlign: TextAlign.center)), + Expanded(flex: 1, child: Text('状态', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF666666)), textAlign: TextAlign.center)), + ], + ), + ), + // 数据行 + ...indicators.map((ind) { + final i = ind as Map? ?? {}; + final name = i['name'] as String? ?? ''; + final value = i['value'] as String? ?? ''; + final status = i['status'] as String? ?? 'normal'; + final refRange = i['refRange'] as String? ?? ''; + final isAbnormal = status != 'normal'; + Color sc; + switch (status) { + case 'high': sc = const Color(0xFFE53935); break; + case 'low': sc = const Color(0xFFF9A825); break; + default: sc = const Color(0xFF43A047); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isAbnormal ? const Color(0xFFFFF8F5) : Colors.transparent, + border: Border( + bottom: BorderSide(color: const Color(0xFFF0F0F0), width: 0.5), + ), + ), + child: Row( + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: TextStyle(fontSize: 13, color: isAbnormal ? const Color(0xFFE53935) : const Color(0xFF333333), fontWeight: isAbnormal ? FontWeight.w600 : FontWeight.normal)), + if (refRange.isNotEmpty) Text('参考:$refRange', style: const TextStyle(fontSize: 10, color: Color(0xFFAAAAAA))), + ], + ), + ), + Expanded(flex: 1, child: Text(value, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: sc))), + Expanded( + flex: 1, + child: Center( + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: sc), + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + + // AI 解读摘要 + if (summary.isNotEmpty) ...[ + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF3EFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFDDD8FF), width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.auto_awesome, size: 16, color: Color(0xFF635BFF)), + SizedBox(width: 6), + Text('AI 解读摘要', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))), + ], + ), + const SizedBox(height: 8), + Text(summary, style: const TextStyle(fontSize: 13, color: Color(0xFF555555), height: 1.5)), + ], + ), + ), + ], + + // 查看完整解读按钮 + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: _cardFilledBtn('查看完整解读', Icons.article_outlined), + ), + const SizedBox(height: 4), + ], + ), + ), + ], + ), + ), + ); + } + + // ═══════════════════════════════════════════════════════════ + // 6. QuickOptionsCard — 优化样式 + // ═══════════════════════════════════════════════════════════ + + Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final options = meta?['options'] as List? ?? []; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(msg.content, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A2E))), + const SizedBox(height: 14), + Wrap(spacing: 8, runSpacing: 8, children: options.map((opt) { + final o = opt as Map? ?? {}; + return ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF5F3FF), + foregroundColor: const Color(0xFF635BFF), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11), + ), + child: Text(o['label'] as String? ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + ); + }).toList()), + ], + ), + ), + ), + ); + } + + // ═══════════════════════════════════════════════════════════ + // 公共组件:思考气泡 & 文本气泡 + // ═══════════════════════════════════════════════════════════ + Widget _buildThinkingBubble(BuildContext context, String? thinkingText) { return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), decoration: BoxDecoration( color: const Color(0xFFFEFEFF), borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)), border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), - boxShadow: const [BoxShadow(color: Color(0xFF635BFF), blurRadius: 4, offset: Offset(0, 2))], + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 24, - height: 24, - padding: const EdgeInsets.all(4), + width: 26, + height: 26, + padding: const EdgeInsets.all(5), decoration: BoxDecoration( color: const Color(0xFFEDEBFF), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(13), ), - child: const CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF)), + child: const CircularProgressIndicator(strokeWidth: 2.2, color: Color(0xFF635BFF)), ), const SizedBox(width: 10), - const Text('正在分析...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), + Text(thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))), ], ), ), @@ -111,7 +976,7 @@ class ChatMessagesView extends ConsumerWidget { alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( color: isUser ? const Color(0xFF635BFF) : const Color(0xFFFEFEFF), @@ -122,7 +987,7 @@ class ChatMessagesView extends ConsumerWidget { bottomRight: const Radius.circular(20), ), border: isUser ? null : Border.all(color: const Color(0xFFE8E6FF), width: 1.5), - boxShadow: isUser ? [] : [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], + boxShadow: isUser ? [] : [BoxShadow(color: const Color(0xFF635BFF).withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -137,18 +1002,18 @@ class ChatMessagesView extends ConsumerWidget { p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5), h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), h2: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), - code: const TextStyle(fontSize: 14, backgroundColor: Colors.grey), + code: const TextStyle(fontSize: 14, backgroundColor: Color(0xFFF5F3FF)), ), ), - if (!isUser && !msg.content.isEmpty) + if (!isUser && msg.content.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 10), child: Row(children: [ const CircleAvatar(radius: 10, backgroundColor: Color(0xFFEDEBFF), child: Icon(Icons.chat_bubble_outline, size: 14, color: Color(0xFF635BFF))), const SizedBox(width: 6), - Text('健康管家', style: TextStyle(fontSize: 12, color: Colors.grey[400])), + const Text('健康管家', style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))), const SizedBox(width: 4), - Text('仅供参考', style: TextStyle(fontSize: 11, color: Colors.grey[300])), + const Text('仅供参考', style: TextStyle(fontSize: 11, color: Color(0xFFCCCCCC))), ]), ), ], @@ -157,329 +1022,71 @@ class ChatMessagesView extends ConsumerWidget { ); } - Widget _buildDataConfirmCard(BuildContext context, ChatMessage msg) { - final meta = msg.metadata; - final metricType = meta?['type'] as String? ?? ''; - final value = meta?['value'] as String? ?? ''; - final abnormal = meta?['abnormal'] as bool? ?? false; + // ═══════════════════════════════════════════════════════════ + // 公共组件:通用按钮 + // ═══════════════════════════════════════════════════════════ - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Color(0xFFF5F3FF), - borderRadius: BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)), - ), - child: Row(children: [ - const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047)), - const SizedBox(width: 8), - const Text('已记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF43A047))), - ]), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column(children: [ - Row(children: [ - Text( - _getMetricIcon(metricType), - style: const TextStyle(fontSize: 24), - ), - const SizedBox(width: 12), - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(_getMetricName(metricType), style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), - const SizedBox(height: 4), - Text(value, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: abnormal ? const Color(0xFFE53935) : const Color(0xFF1A1A1A))), - ]), - const Spacer(), - if (abnormal) const Icon(Icons.warning_amber, size: 20, color: Color(0xFFE53935)), - ]), - if (abnormal) - const Padding( - padding: EdgeInsets.only(top: 12), - child: Text('⚠️ 数值超出正常范围,请关注', style: TextStyle(fontSize: 14, color: Color(0xFFE53935))), - ), - const SizedBox(height: 12), - Row(children: [ - Expanded( - child: OutlinedButton( - onPressed: () {}, - child: const Text('编辑'), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF635BFF), - side: const BorderSide(color: Color(0xFF635BFF)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton( - onPressed: () {}, - child: const Text('确认'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF635BFF), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - ), - ), - ]), - ]), - ), - ], - ), - ), - ); - } - - Widget _buildMedicationConfirmCard(BuildContext context, ChatMessage msg) { - final meta = msg.metadata; - final name = meta?['name'] as String? ?? ''; - final dosage = meta?['dosage'] as String? ?? ''; - final time = meta?['time'] as String? ?? ''; - - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column(children: [ - Row(children: [ - const Text('💊', style: TextStyle(fontSize: 28)), - const SizedBox(width: 12), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), - if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), - if (time.isNotEmpty) Text('每天 $time', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), - ]), - ), - ]), - const SizedBox(height: 16), - const Text('需要调整吗?', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), - const SizedBox(height: 12), - Row(children: [ - Expanded(child: _medBtn('确认', Icons.check, Colors.white, const Color(0xFF635BFF))), - const SizedBox(width: 8), - Expanded(child: _medBtn('修改时间', Icons.access_time, const Color(0xFF635BFF), Colors.white)), - const SizedBox(width: 8), - Expanded(child: _medBtn('改剂量', Icons.edit, const Color(0xFF635BFF), Colors.white)), - ]), - ]), - ), - ), - ); - } - - Widget _medBtn(String label, IconData icon, Color textColor, Color bgColor) { + Widget _cardFilledBtn(String label, IconData icon) { return ElevatedButton( onPressed: () {}, - child: Row(children: [Icon(icon, size: 16), const SizedBox(width: 4), Text(label, style: TextStyle(fontSize: 12))]), style: ElevatedButton.styleFrom( - backgroundColor: bgColor, - foregroundColor: textColor, + backgroundColor: const Color(0xFF635BFF), + foregroundColor: Colors.white, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 11), ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 16), + const SizedBox(width: 5), + Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), + ]), ); } - Widget _buildDietAnalysisCard(BuildContext context, ChatMessage msg) { - final meta = msg.metadata; - final foods = meta?['foods'] as List? ?? []; - final totalCalories = meta?['totalCalories'] as int? ?? 0; - final rating = meta?['rating'] as int? ?? 0; - final warnings = meta?['warnings'] as List? ?? []; - - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('🍽️ 饮食分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - Column(children: foods.map((food) { - final f = food as Map? ?? {}; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row(children: [ - Text(f['name'] as String? ?? '', style: const TextStyle(fontSize: 14)), - const Spacer(), - Text('${f['calories'] ?? 0} kcal', style: TextStyle(fontSize: 14, color: Colors.grey[500])), - ]), - ); - }).toList()), - const SizedBox(height: 12), - Row(children: [ - const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), - const Spacer(), - Text('$totalCalories kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - ]), - const SizedBox(height: 12), - Row(children: [ - const Text('健康评分', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), - const SizedBox(width: 8), - Row(children: List.generate(5, (i) => Icon(Icons.star, size: 16, color: i < rating ? const Color(0xFFFFB800) : Colors.grey[300]))), - ]), - if (warnings.isNotEmpty) ...[ - const SizedBox(height: 12), - ...warnings.map((w) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text('⚠️ $w', style: TextStyle(fontSize: 14, color: const Color(0xFFE53935))), - )), - ], - const SizedBox(height: 12), - const Text('建议:饮食均衡,多吃蔬菜水果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), - ]), - ), + Widget _cardOutlineBtn(String label, IconData icon) { + return OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF635BFF), + side: const BorderSide(color: Color(0xFF635BFF), width: 1.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 11), ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 15), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), + ]), ); } - Widget _buildReportAnalysisCard(BuildContext context, ChatMessage msg) { - final meta = msg.metadata; - final reportType = meta?['type'] as String? ?? ''; - final indicators = meta?['indicators'] as List? ?? []; + // ═══════════════════════════════════════════════════════════ + // 工具方法 + // ═══════════════════════════════════════════════════════════ - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('📋', style: TextStyle(fontSize: 20)), - const SizedBox(width: 8), - Text(reportType, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - ]), - const SizedBox(height: 12), - const Text('AI 预解读结果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), - const SizedBox(height: 8), - Column(children: indicators.map((ind) { - final i = ind as Map? ?? {}; - final name = i['name'] as String? ?? ''; - final value = i['value'] as String? ?? ''; - final status = i['status'] as String? ?? 'normal'; - Color statusColor; - switch (status) { - case 'high': statusColor = const Color(0xFFE53935); break; - case 'low': statusColor = const Color(0xFFF9A825); break; - default: statusColor = const Color(0xFF43A047); - } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row(children: [ - Expanded(child: Text(name, style: const TextStyle(fontSize: 14))), - Text(value, style: TextStyle(fontSize: 14, color: statusColor, fontWeight: FontWeight.w600)), - const SizedBox(width: 8), - Icon(status == 'normal' ? Icons.check_circle : Icons.warning_amber, size: 16, color: statusColor), - ]), - ); - }).toList()), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFFEF3C7), - borderRadius: BorderRadius.circular(12), - ), - child: const Text('⚠️ AI 预解读,待医生确认', style: TextStyle(fontSize: 13, color: Color(0xFFD97706))), - ), - const SizedBox(height: 12), - Center( - child: OutlinedButton( - onPressed: () {}, - child: const Text('查看原始图片'), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF635BFF), - side: const BorderSide(color: Color(0xFF635BFF)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - ), - ), - ]), - ), - ), - ); + String _formatTime(DateTime dt) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final thatDay = DateTime(dt.year, dt.month, dt.day); + if (thatDay == today) { + return '今天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + if (thatDay == today.subtract(const Duration(days: 1))) { + return '昨天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } - Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) { - final meta = msg.metadata; - final options = meta?['options'] as List? ?? []; - - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column(children: [ - Text(msg.content, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))), - const SizedBox(height: 12), - Wrap(spacing: 8, runSpacing: 8, children: options.map((opt) { - final o = opt as Map? ?? {}; - return ElevatedButton( - onPressed: () {}, - child: Text(o['label'] as String? ?? '', style: const TextStyle(fontSize: 14)), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFF5F3FF), - foregroundColor: const Color(0xFF635BFF), - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - ), - ); - }).toList()), - ]), - ), - ), - ); + String _getMetricUnit(String type) { + switch (type.toLowerCase()) { + case 'blood_pressure': return 'mmHg'; + case 'heart_rate': return 'bpm'; + case 'glucose': return 'mmol/L'; + case 'spo2': return '%'; + case 'weight': return 'kg'; + default: return ''; + } } String _getMetricIcon(String type) { @@ -503,4 +1110,118 @@ class ChatMessagesView extends ConsumerWidget { default: return '健康指标'; } } + + static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) { + return switch (agent) { + ActiveAgent.health => (Icons.favorite_border, '记数据', '录入血压、血糖、心率等日常指标'), + ActiveAgent.diet => (Icons.restaurant, '拍饮食', '拍照识别食物热量和营养成分'), + ActiveAgent.medication => (Icons.medication, '药管家', '管理药品、提醒服药、追踪用量'), + ActiveAgent.consultation => (Icons.local_hospital, '问诊', '在线咨询医生,描述症状获取建议'), + ActiveAgent.report => (Icons.assignment, '看报告', '上传体检报告,AI 辅助解读'), + ActiveAgent.exercise => (Icons.directions_run, '运动', '制定运动计划,打卡记录进度'), + _ => (Icons.smart_toy, 'AI 助手', '您的智能健康管家'), + }; + } +} + +// ════════════════════════════════════════════════════════════════ +// 内部数据类 +// ════════════════════════════════════════════════════════════════ + +typedef _AgentIcon = IconData; + +class _AgentAction { + final String label; + final IconData icon; + final bool isWide; + + const _AgentAction({required this.label, required this.icon, this.isWide = false}); +} + +final _agentActions = >{ + ActiveAgent.health: [ + _AgentAction(label: '录入血压', icon: Icons.monitor_heart_outlined), + _AgentAction(label: '录入血糖', icon: Icons.bloodtype_outlined), + _AgentAction(label: '录入心率', icon: Icons.favorite_border), + _AgentAction(label: '录入血氧', icon: Icons.air_outlined), + _AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined), + ], + ActiveAgent.diet: [ + _AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true), + _AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true), + _AgentAction(label: '看舌答', icon: Icons.face_retouching_natural_outlined, isWide: true), + _AgentAction(label: '测肤质', icon: Icons.palette_outlined, isWide: true), + ], + ActiveAgent.medication: [ + _AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true), + _AgentAction(label: '用药提醒', icon: Icons.alarm_outlined, isWide: true), + _AgentAction(label: '添加药品', icon: Icons.add_circle_outline, isWide: true), + ], + ActiveAgent.consultation: [ + _AgentAction(label: '找医生', icon: Icons.person_search_outlined, isWide: true), + _AgentAction(label: '描述症状', icon: Icons.edit_note_outlined, isWide: true), + ], + ActiveAgent.report: [ + _AgentAction(label: '上传报告', icon: Icons.upload_file_outlined, isWide: true), + _AgentAction(label: '查看历史', icon: Icons.history_outlined, isWide: true), + ], + ActiveAgent.exercise: [ + _AgentAction(label: '本周计划', icon: Icons.calendar_month_outlined, isWide: true), + _AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true), + _AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true), + ], +}; + +extension _AgentActionsExt on ActiveAgent { + List<_AgentAction> get actions => _agentActions[this] ?? [const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)]; +} + +// ════════════════════════════════════════════════════════════════ +// 可展开的 AI 建议小组件 +// ════════════════════════════════════════════════════════════════ + +class _ExpandableAdvice extends StatefulWidget { + final String advice; + const _ExpandableAdvice({required this.advice}); + + @override + State<_ExpandableAdvice> createState() => _ExpandableAdviceState(); +} + +class _ExpandableAdviceState extends State<_ExpandableAdvice> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF7F5FF), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.lightbulb_outline, size: 16, color: Color(0xFF635BFF)), + const SizedBox(width: 6), + const Text('AI 建议', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))), + const Spacer(), + Icon(_expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 18, color: const Color(0xFFAAAAAA)), + ], + ), + if (_expanded) ...[ + const SizedBox(height: 10), + Text(widget.advice, style: const TextStyle(fontSize: 13, color: Color(0xFF555555), height: 1.6)), + ], + ], + ), + ), + ); + } } diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 111f543..e8f84d9 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -4,7 +4,7 @@ import 'auth_provider.dart'; import 'data_providers.dart'; import '../utils/sse_handler.dart'; -enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions } +enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome } class ChatMessage { final String id; @@ -236,6 +236,7 @@ class ChatNotifier extends Notifier { case 'diet_analysis': return MessageType.dietAnalysis; case 'report_analysis': return MessageType.reportAnalysis; case 'quick_options': return MessageType.quickOptions; + case 'agent_welcome': return MessageType.agentWelcome; default: return MessageType.text; } }