Files
AI-Health/health_app/lib/pages/home/widgets/chat_messages_view.dart
MingNian 9fb60cb3cf feat: 聊天卡片升级+趋势图重写+智能体欢迎卡片
- AgentWelcomeCard:紫色渐变头部+快捷按钮网格+智能体描述
- DataConfirmCard:绿色渐变确认条+迷你趋势图+编辑/确认按钮
- MedicationConfirmCard:药丸图标+剩余药量进度条+确认/跳过
- DietAnalysisCard:大号热量+营养素圆环+食物明细+AI建议
- ReportAnalysisCard:指标表格+异常高亮+AI解读
- trend_page 重写:CustomPaint 平滑曲线+当前值卡片+统计摘要
- chat_provider 新增 agentWelcome 消息类型
2026-06-03 14:25:48 +08:00

1228 lines
55 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/chat_provider.dart';
/// 对话消息列表
class ChatMessagesView extends ConsumerWidget {
final ScrollController scrollCtrl;
final List<ChatMessage> messages;
const ChatMessagesView({super.key, required this.scrollCtrl, required this.messages});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chatState = ref.watch(chatProvider);
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFEDEBFF),
borderRadius: BorderRadius.circular(40),
),
child: const Icon(Icons.health_and_safety, size: 40, color: Color(0xFF635BFF)),
),
const SizedBox(height: 16),
Text('开始和 AI 健康管家对话吧', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 8),
const Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 14, color: Color(0xFF9E9E9E))),
],
),
);
}
return ListView.builder(
controller: scrollCtrl,
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
return _buildMessageContent(context, msg, chatState);
},
);
}
// ─── 消息分发 ─────────────────────────────────────────────
Widget _buildMessageContent(BuildContext context, ChatMessage msg, ChatState chatState) {
final isUser = msg.isUser;
if (!isUser && chatState.isStreaming && msg.content.isEmpty) {
return _buildThinkingBubble(context, chatState.thinkingText);
}
switch (msg.type) {
case MessageType.agentWelcome:
return _buildAgentWelcomeCard(context, msg, chatState.activeAgent);
case MessageType.dataConfirm:
return _buildDataConfirmCard(context, msg);
case MessageType.medicationConfirm:
return _buildMedicationConfirmCard(context, msg);
case MessageType.dietAnalysis:
return _buildDietAnalysisCard(context, msg);
case MessageType.reportAnalysis:
return _buildReportAnalysisCard(context, msg);
case MessageType.quickOptions:
return _buildQuickOptionsCard(context, msg);
default:
return _buildTextBubble(context, msg);
}
}
// ═══════════════════════════════════════════════════════════
// 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>(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>(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<Color>(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: 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: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 26,
height: 26,
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: const Color(0xFFEDEBFF),
borderRadius: BorderRadius.circular(13),
),
child: const CircularProgressIndicator(strokeWidth: 2.2, color: Color(0xFF635BFF)),
),
const SizedBox(width: 10),
Text(thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
],
),
),
);
}
Widget _buildTextBubble(BuildContext context, ChatMessage msg) {
final isUser = msg.isUser;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
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),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 20 : 4),
topRight: Radius.circular(isUser ? 4 : 20),
bottomLeft: const Radius.circular(20),
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(12), blurRadius: 10, offset: const Offset(0, 3))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isUser)
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
else
MarkdownBody(
data: msg.content,
selectable: true,
styleSheet: MarkdownStyleSheet(
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: Color(0xFFF5F3FF)),
),
),
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),
const Text('健康管家', style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
const SizedBox(width: 4),
const Text('仅供参考', style: TextStyle(fontSize: 11, color: Color(0xFFCCCCCC))),
]),
),
],
),
),
);
}
// ═══════════════════════════════════════════════════════════
// 公共组件:通用按钮
// ═══════════════════════════════════════════════════════════
Widget _cardFilledBtn(String label, IconData icon) {
return ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF635BFF),
foregroundColor: Colors.white,
elevation: 0,
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 _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)),
]),
);
}
// ═══════════════════════════════════════════════════════════
// 工具方法
// ═══════════════════════════════════════════════════════════
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')}';
}
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) {
switch (type.toLowerCase()) {
case 'blood_pressure': return '🩺';
case 'heart_rate': return '💓';
case 'glucose': return '💉';
case 'spo2': return '🫁';
case 'weight': return '⚖️';
default: return '📊';
}
}
String _getMetricName(String type) {
switch (type.toLowerCase()) {
case 'blood_pressure': return '血压';
case 'heart_rate': return '心率';
case 'glucose': return '血糖';
case 'spo2': return '血氧';
case 'weight': return '体重';
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, List<_AgentAction>>{
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)),
],
],
),
),
);
}
}