- 后端: remaining_endpoints拆分为6个独立文件 - 后端: AI Agent Handler从ai_chat_endpoints抽取为7个独立处理器 - 后端: 食物识别prompt改为输出结构化JSON - 前端: 饮食识别从Mock替换为真实VLM API调用 - 前端: 首页图片上传URL修复(/api/upload→/api/files/upload) - 前端: 拍饮食按钮导航到独立DietCapturePage - 前端: 删除无用agent_bar.dart - 前端: 修复widget_test.dart过期属性名 - 前端: 恢复ServicePackageCard和详情页 - 新增6份实施文档(情况/问诊/报告/建档/日历/视觉统一)
1531 lines
68 KiB
Dart
1531 lines
68 KiB
Dart
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../../core/navigation_provider.dart';
|
||
import '../../../providers/chat_provider.dart';
|
||
import '../../../providers/data_providers.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(0xFFF0F2FF),
|
||
borderRadius: BorderRadius.circular(40),
|
||
),
|
||
child: const Icon(Icons.health_and_safety, size: 40, color: Color(0xFF8B9CF7)),
|
||
),
|
||
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, ref, msg, chatState);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ─── 消息分发 ─────────────────────────────────────────────
|
||
|
||
Widget _buildMessageContent(BuildContext context, WidgetRef ref, ChatMessage msg, ChatState chatState) {
|
||
switch (msg.type) {
|
||
case MessageType.agentWelcome:
|
||
final storedAgent = _parseAgentFromName(msg.metadata?['agent'] as String?);
|
||
return _buildAgentWelcomeCard(context, ref, msg, storedAgent);
|
||
case MessageType.taskCard:
|
||
return _buildTaskCardInChat(context, ref);
|
||
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:
|
||
// 只有当前正在流式回复的文本消息才显示"正在分析"
|
||
if (!msg.isUser && chatState.isStreaming && msg.content.isEmpty) {
|
||
return _buildThinkingBubble(context, chatState.thinkingText);
|
||
}
|
||
return _buildTextBubble(context, msg);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 1. AgentWelcomeCard — 智能体欢迎卡片
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _buildAgentWelcomeCard(BuildContext context, WidgetRef ref, ChatMessage msg, ActiveAgent agent) {
|
||
final info = _agentInfo(agent);
|
||
final actions = agent.actions;
|
||
final screenWidth = MediaQuery.of(context).size.width;
|
||
final colors = _agentColors(agent);
|
||
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
constraints: BoxConstraints(maxWidth: screenWidth * 0.92),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [
|
||
BoxShadow(color: colors.gradient[0].withAlpha(30), 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: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: colors.gradient,
|
||
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(0xFFE8E6FF))),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// ── 快捷操作按钮网格 ──
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
|
||
child: Wrap(
|
||
spacing: 10,
|
||
runSpacing: 10,
|
||
children: agent == ActiveAgent.consultation
|
||
? _buildDoctorCards(screenWidth, ref)
|
||
: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(),
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 12),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref, _AgentColors colors) {
|
||
return InkWell(
|
||
onTap: () {
|
||
if (a.action == 'createPlan') {
|
||
_createExercisePlan(ref, context);
|
||
} else if (a.action == 'checkIn') {
|
||
_exerciseCheckIn(ref, context);
|
||
} else if (a.label == '服药打卡') {
|
||
_medicationCheckIn(ref, context);
|
||
} else if (a.route != null) {
|
||
if (a.route == 'camera' || a.route == 'gallery') {
|
||
ref.read(cameraActionProvider.notifier).trigger(a.route!);
|
||
} else {
|
||
pushRoute(ref, a.route!);
|
||
}
|
||
}
|
||
},
|
||
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: colors.bg,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: colors.border, width: 1),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: colors.iconBg,
|
||
borderRadius: BorderRadius.circular(11),
|
||
),
|
||
child: Icon(a.icon, size: 20, color: colors.accent),
|
||
),
|
||
const SizedBox(height: 7),
|
||
Text(a.label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF333333))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _buildDoctorCards(double screenWidth, WidgetRef ref) {
|
||
const doctors = [
|
||
{'name': '张医生', 'title': '主任医师', 'dept': '心内科', 'desc': '冠心病、高血压术后管理', 'id': 'doc_1'},
|
||
{'name': '李医生', 'title': '副主任医师', 'dept': '内分泌科', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'doc_2'},
|
||
{'name': '王医生', 'title': '主治医师', 'dept': '营养科', 'desc': '术后营养指导、饮食方案制定', 'id': 'doc_3'},
|
||
];
|
||
return doctors.map((d) => _doctorCard(d, screenWidth, ref)).toList();
|
||
}
|
||
|
||
Widget _doctorCard(Map<String, String> doc, double screenWidth, WidgetRef ref) {
|
||
return InkWell(
|
||
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}),
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: Container(
|
||
width: screenWidth * 0.38,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: const Color(0xFFF0F2FF)),
|
||
),
|
||
child: Column(children: [
|
||
CircleAvatar(
|
||
radius: 24,
|
||
backgroundColor: const Color(0xFFF0F2FF),
|
||
child: Text(doc['name']![0], style: const TextStyle(fontSize: 20, color: Color(0xFF8B9CF7))),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(doc['name']!, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||
Text(doc['title']!, style: const TextStyle(fontSize: 11, color: Color(0xFF999999))),
|
||
const SizedBox(height: 2),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF0F2FF),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(doc['dept']!, style: const TextStyle(fontSize: 10, color: Color(0xFF8B9CF7))),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
doc['desc']!,
|
||
style: const TextStyle(fontSize: 10, color: Color(0xFF888888), height: 1.3),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 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(0xFF8B9CF7).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(0xFF8B9CF7) : 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(0xFF8B9CF7).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(0xFFF0F2FF)]),
|
||
),
|
||
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(0xFF8B9CF7))),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: LinearProgressIndicator(
|
||
value: remaining,
|
||
minHeight: 8,
|
||
backgroundColor: const Color(0xFFEFEDFF),
|
||
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF8B9CF7)),
|
||
),
|
||
),
|
||
|
||
// 操作按钮
|
||
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 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(0xFF8B9CF7).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: [
|
||
// ── 总热量(仅 >0 时显示) ──
|
||
if (totalCalories > 0) ...[
|
||
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),
|
||
],
|
||
|
||
// ── 识别食物列表 ──
|
||
if (foods.isNotEmpty) ...[
|
||
const Text('识别结果', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||
const SizedBox(height: 10),
|
||
...foods.map((food) {
|
||
final f = food is Map ? food : <String, dynamic>{};
|
||
final name = f['name'] as String? ?? '';
|
||
final calories = f['calories'] as num? ?? 0;
|
||
final portion = f['portion'] as String?;
|
||
final nutrients = f['nutrients'] as String?;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(color: const Color(0xFFFAFAFA), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFF0F0F0))),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Expanded(child: Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A)))),
|
||
if (calories > 0) Text('${calories is int ? calories : calories.toInt()} kcal', style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||
]),
|
||
if (portion != null && portion.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 4), child: Text(portion, style: TextStyle(fontSize: 12, color: Colors.grey[500]))),
|
||
if (nutrients != null && nutrients.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 2), child: Text(nutrients, style: TextStyle(fontSize: 11, color: Colors.grey[500]))),
|
||
]),
|
||
);
|
||
}),
|
||
const SizedBox(height: 6),
|
||
],
|
||
|
||
// ── AI 建议 ──
|
||
const SizedBox(height: 14),
|
||
const Text('AI 建议', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||
const SizedBox(height: 6),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)),
|
||
child: Text(advice, style: const TextStyle(fontSize: 13, height: 1.6, color: Color(0xFF555555))),
|
||
),
|
||
]),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 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(0xFF8B9CF7).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(0xFF8B9CF7)),
|
||
SizedBox(width: 6),
|
||
Text('AI 解读摘要', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7))),
|
||
],
|
||
),
|
||
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(0xFF8B9CF7).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(0xFFF0F2FF),
|
||
foregroundColor: const Color(0xFF8B9CF7),
|
||
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(0xFFD8DCFD), width: 1.5),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).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(0xFFF0F2FF),
|
||
borderRadius: BorderRadius.circular(13),
|
||
),
|
||
child: const CircularProgressIndicator(strokeWidth: 2.2, color: Color(0xFF8B9CF7)),
|
||
),
|
||
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;
|
||
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
||
final localPath = msg.metadata?['localImagePath'] as String?;
|
||
final hasImage = imageUrl != null || localPath != null;
|
||
|
||
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(0xFF8B9CF7) : 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(0xFFD8DCFD), width: 1.5),
|
||
boxShadow: isUser ? [] : [BoxShadow(color: const Color(0xFF8B9CF7).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(0xFFF0F2FF)),
|
||
),
|
||
),
|
||
|
||
// 图片缩略图(在文字下方)
|
||
if (hasImage)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: GestureDetector(
|
||
onTap: () => _showFullImage(context, localPath ?? imageUrl),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 160, maxHeight: 120),
|
||
child: localPath != null
|
||
? Image.file(File(localPath), fit: BoxFit.cover)
|
||
: imageUrl != null
|
||
? Image.network(imageUrl, fit: BoxFit.cover, errorBuilder: (_, e, s) => Container(
|
||
width: 80, height: 60,
|
||
decoration: BoxDecoration(color: const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(8)),
|
||
child: const Icon(Icons.image, size: 24, color: Color(0xFFBBBBBB)),
|
||
))
|
||
: const SizedBox.shrink(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
if (!isUser && msg.content.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 10),
|
||
child: Row(children: [
|
||
const CircleAvatar(radius: 10, backgroundColor: Color(0xFFF0F2FF), child: Icon(Icons.chat_bubble_outline, size: 14, color: Color(0xFF8B9CF7))),
|
||
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(0xFF8B9CF7),
|
||
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(0xFF8B9CF7),
|
||
side: const BorderSide(color: Color(0xFF8B9CF7), 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)),
|
||
]),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 工具方法
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
static void _showFullImage(BuildContext context, String? path) {
|
||
if (path == null) return;
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => Dialog(
|
||
backgroundColor: Colors.black,
|
||
insetPadding: const EdgeInsets.all(24),
|
||
child: Stack(alignment: Alignment.center, children: [
|
||
ClipRRect(borderRadius: BorderRadius.circular(12), child: InteractiveViewer(child: path.startsWith('http')
|
||
? Image.network(path, fit: BoxFit.contain)
|
||
: Image.file(File(path), fit: BoxFit.contain))),
|
||
Positioned(top: 8, right: 8, child: GestureDetector(
|
||
onTap: () => Navigator.pop(ctx),
|
||
child: Container(padding: const EdgeInsets.all(6), decoration: BoxDecoration(color: Colors.white54, shape: BoxShape.circle), child: const Icon(Icons.close, size: 18)),
|
||
)),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
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 void _medicationCheckIn(WidgetRef ref, BuildContext context) async {
|
||
try {
|
||
final service = ref.read(medicationServiceProvider);
|
||
final reminders = await ref.read(medicationReminderProvider.future);
|
||
if (reminders.isEmpty) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('暂无待服药记录'), backgroundColor: Color(0xFFFF9800)),
|
||
);
|
||
return;
|
||
}
|
||
for (final m in reminders) {
|
||
await service.confirm(m['id']?.toString() ?? '');
|
||
}
|
||
ref.invalidate(medicationReminderProvider);
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('打卡成功 已记录 ${reminders.length} 项服药'),
|
||
backgroundColor: const Color(0xFF43A047),
|
||
),
|
||
);
|
||
} catch (e) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
|
||
static void _createExercisePlan(WidgetRef ref, BuildContext context) async {
|
||
try {
|
||
final service = ref.read(exerciseServiceProvider);
|
||
final today = DateTime.now();
|
||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||
final items = List.generate(7, (i) => {
|
||
'dayOfWeek': i,
|
||
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
|
||
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
|
||
'isRestDay': i == 2 || i == 5,
|
||
});
|
||
await service.createPlan({
|
||
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
|
||
'items': items,
|
||
});
|
||
ref.invalidate(currentExercisePlanProvider);
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('运动计划已创建'), backgroundColor: Color(0xFF43A047)),
|
||
);
|
||
pushRoute(ref, 'exercisePlan');
|
||
} catch (e) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('创建失败:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
|
||
static void _exerciseCheckIn(WidgetRef ref, BuildContext context) async {
|
||
try {
|
||
final plan = await ref.read(currentExercisePlanProvider.future);
|
||
if (plan == null || plan.isEmpty) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('请先创建运动计划'), backgroundColor: Color(0xFFFF9800)),
|
||
);
|
||
return;
|
||
}
|
||
final items = (plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final today = DateTime.now().weekday - 1;
|
||
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||
(i) => i?['dayOfWeek'] == today && i?['isRestDay'] != true,
|
||
orElse: () => null,
|
||
);
|
||
if (todayItem == null) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('今天休息日'), backgroundColor: Color(0xFFFF9800)),
|
||
);
|
||
return;
|
||
}
|
||
final service = ref.read(exerciseServiceProvider);
|
||
await service.checkIn(todayItem['id']?.toString() ?? '');
|
||
ref.invalidate(currentExercisePlanProvider);
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('打卡成功'), backgroundColor: Color(0xFF43A047)),
|
||
);
|
||
} catch (e) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
|
||
static _AgentColors _agentColors(ActiveAgent agent) {
|
||
return switch (agent) {
|
||
ActiveAgent.consultation => _AgentColors(
|
||
gradient: [const Color(0xFFC5D5F8), const Color(0xFFA0B8F0), const Color(0xFF7B98E0)],
|
||
bg: const Color(0xFFF0F4FF),
|
||
border: const Color(0xFFD8E0FA),
|
||
iconBg: const Color(0xFFE4ECFC),
|
||
accent: const Color(0xFF7B98E0),
|
||
),
|
||
ActiveAgent.health => _AgentColors(
|
||
gradient: [const Color(0xFFB8E6CF), const Color(0xFF8ED4AE), const Color(0xFF5FB88D)],
|
||
bg: const Color(0xFFF0FAF4),
|
||
border: const Color(0xFFD0ECD8),
|
||
iconBg: const Color(0xFFE4F8EC),
|
||
accent: const Color(0xFF5FB88D),
|
||
),
|
||
ActiveAgent.diet => _AgentColors(
|
||
gradient: [const Color(0xFFFFD8B8), const Color(0xFFFFC896), const Color(0xFFF0A060)],
|
||
bg: const Color(0xFFFFF6F0),
|
||
border: const Color(0xFFFFE8D4),
|
||
iconBg: const Color(0xFFFFEEDC),
|
||
accent: const Color(0xFFF0A060),
|
||
),
|
||
ActiveAgent.medication => _AgentColors(
|
||
gradient: [const Color(0xFFFFD4E0), const Color(0xFFFFB8CC), const Color(0xFFE898A8)],
|
||
bg: const Color(0xFFFFF0F4),
|
||
border: const Color(0xFFFFE0E8),
|
||
iconBg: const Color(0xFFFFE8EE),
|
||
accent: const Color(0xFFE898A8),
|
||
),
|
||
ActiveAgent.report => _AgentColors(
|
||
gradient: [const Color(0xFFD8D0F0), const Color(0xFFC4B8EC), const Color(0xFFA898D8)],
|
||
bg: const Color(0xFFF8F4FF),
|
||
border: const Color(0xFFECE4F8),
|
||
iconBg: const Color(0xFFF0E8FC),
|
||
accent: const Color(0xFFA898D8),
|
||
),
|
||
ActiveAgent.exercise => _AgentColors(
|
||
gradient: [const Color(0xFFB8E0E0), const Color(0xFF90D0D0), const Color(0xFF68B4B4)],
|
||
bg: const Color(0xFFF0FAFA),
|
||
border: const Color(0xFFD4ECEC),
|
||
iconBg: const Color(0xFFE4F4F4),
|
||
accent: const Color(0xFF68B4B4),
|
||
),
|
||
_ => _AgentColors(
|
||
gradient: [const Color(0xFFC5D0F8), const Color(0xFFA0B0F0), const Color(0xFF7B90E0)],
|
||
bg: const Color(0xFFF5F5FF),
|
||
border: const Color(0xFFE0E0F8),
|
||
iconBg: const Color(0xFFEDEDFC),
|
||
accent: const Color(0xFF7B90E0),
|
||
),
|
||
};
|
||
}
|
||
|
||
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 助手', '您的智能健康管家'),
|
||
};
|
||
}
|
||
|
||
static ActiveAgent _parseAgentFromName(String? name) {
|
||
switch (name) {
|
||
case 'consultation': return ActiveAgent.consultation;
|
||
case 'health': return ActiveAgent.health;
|
||
case 'diet': return ActiveAgent.diet;
|
||
case 'medication': return ActiveAgent.medication;
|
||
case 'report': return ActiveAgent.report;
|
||
case 'exercise': return ActiveAgent.exercise;
|
||
default: return ActiveAgent.default_;
|
||
}
|
||
}
|
||
|
||
Widget _buildTaskCardInChat(BuildContext context, WidgetRef ref) {
|
||
final health = ref.watch(latestHealthProvider);
|
||
final reminders = ref.watch(medicationReminderProvider);
|
||
return health.when(
|
||
data: (data) => _taskCardBubble(context, ref, data, reminders),
|
||
loading: () => _taskCardBubble(context, ref, {}, const AsyncValue.loading()),
|
||
error: (_, e) => _taskCardBubble(context, ref, {}, const AsyncValue.loading()),
|
||
);
|
||
}
|
||
|
||
Widget _taskCardBubble(BuildContext context, WidgetRef ref, Map<String, dynamic> healthData, AsyncValue<List<Map<String, dynamic>>> reminders) {
|
||
final now = DateTime.now();
|
||
final tasks = <Widget>[];
|
||
|
||
// 1. 健康数据摘要行
|
||
final bp = healthData['BloodPressure'];
|
||
final bpText = bp is Map ? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}' : null;
|
||
final hr = healthData['HeartRate'];
|
||
final hrText = hr is int ? '$hr' : null;
|
||
final bs = healthData['BloodSugar'];
|
||
final bsText = bs is num ? '$bs' : null;
|
||
final bo = healthData['BloodOxygen'];
|
||
final boText = bo is num ? '$bo' : null;
|
||
final wt = healthData['Weight'];
|
||
final wtText = wt is num ? '$wt' : null;
|
||
|
||
final allNull = bpText == null && hrText == null && bsText == null && boText == null && wtText == null;
|
||
|
||
if (!allNull) {
|
||
tasks.add(_taskRow(context, Icons.check_circle, '今日已记录', trailing: [
|
||
if (bpText != null) '血压 $bpText',
|
||
if (hrText != null) '心率 $hrText',
|
||
if (bsText != null) '血糖 $bsText',
|
||
if (boText != null) '血氧 $boText',
|
||
if (wtText != null) '体重 $wtText',
|
||
].join(' · '), status: 'done', onTap: () => pushRoute(ref, 'trend')));
|
||
}
|
||
|
||
// 2. 用药提醒
|
||
reminders.whenOrNull(data: (meds) {
|
||
for (final m in meds) {
|
||
final name = m['name'] ?? '';
|
||
final dosage = m['dosage'] ?? '';
|
||
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(', ') ?? '';
|
||
final medOverdue = now.hour >= 8;
|
||
tasks.add(_taskRow(
|
||
context, Icons.medication_rounded, '$name $dosage ($times)',
|
||
status: medOverdue ? 'overdue' : 'pending',
|
||
onTap: () {},
|
||
));
|
||
}
|
||
});
|
||
if (tasks.length <= (allNull ? 0 : 1)) {
|
||
tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending'));
|
||
}
|
||
|
||
// 3. 运动
|
||
final exOverdue = now.hour >= 18;
|
||
tasks.add(_taskRow(
|
||
context, Icons.directions_run, '今日待运动:散步 30 分钟',
|
||
status: exOverdue ? 'overdue' : 'pending',
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
));
|
||
|
||
// 4. 测量
|
||
tasks.add(_taskRow(
|
||
context, Icons.today, '今日测量:血压',
|
||
status: 'pending',
|
||
onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||
));
|
||
|
||
// 5. 异常指标
|
||
if (bp is Map) {
|
||
final s = bp['systolic'];
|
||
if (s is int && s >= 140) {
|
||
tasks.add(_taskRow(
|
||
context, Icons.warning_amber_rounded, '血压 $s/${bp['diastolic'] ?? '--'} 偏高',
|
||
status: 'warning',
|
||
onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||
));
|
||
}
|
||
}
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Icon(Icons.today, size: 18, color: const Color(0xFF8B9CF7)),
|
||
const SizedBox(width: 8),
|
||
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||
]),
|
||
const SizedBox(height: 10),
|
||
...tasks,
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _taskRow(BuildContext context, IconData icon, String label, {String status = 'pending', String? trailing, VoidCallback? onTap}) {
|
||
final colors = {'done': const Color(0xFF43A047), 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E), 'overdue': const Color(0xFFE53935)};
|
||
final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined, 'overdue': Icons.error};
|
||
final isOverdue = status == 'overdue';
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
||
Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF8B9CF7))),
|
||
const SizedBox(width: 10),
|
||
Expanded(child: Text(trailing ?? label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))),
|
||
Icon(icons[status] ?? Icons.circle_outlined, size: 18, color: colors[status] ?? Colors.grey),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// 内部数据类
|
||
// ════════════════════════════════════════════════════════════════
|
||
|
||
typedef _AgentIcon = IconData;
|
||
|
||
class _AgentColors {
|
||
final List<Color> gradient;
|
||
final Color bg;
|
||
final Color border;
|
||
final Color iconBg;
|
||
final Color accent;
|
||
const _AgentColors({
|
||
required this.gradient,
|
||
required this.bg,
|
||
required this.border,
|
||
required this.iconBg,
|
||
required this.accent,
|
||
});
|
||
}
|
||
|
||
class _AgentAction {
|
||
final String label;
|
||
final IconData icon;
|
||
final bool isWide;
|
||
final String? route;
|
||
final String? action;
|
||
|
||
const _AgentAction({required this.label, required this.icon, this.isWide = false, this.route, this.action});
|
||
}
|
||
|
||
final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||
ActiveAgent.health: [
|
||
_AgentAction(label: '录入血压', icon: Icons.monitor_heart_outlined, route: 'trend'),
|
||
_AgentAction(label: '录入血糖', icon: Icons.bloodtype_outlined, route: 'trend'),
|
||
_AgentAction(label: '录入心率', icon: Icons.favorite_border, route: 'trend'),
|
||
_AgentAction(label: '录入血氧', icon: Icons.air_outlined, route: 'trend'),
|
||
_AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined, route: 'trend'),
|
||
],
|
||
ActiveAgent.diet: [
|
||
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, route: 'dietCapture'),
|
||
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, route: 'dietCapture'),
|
||
],
|
||
ActiveAgent.medication: [
|
||
_AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true, route: 'medications'),
|
||
_AgentAction(label: '服药打卡', icon: Icons.check_circle_outline, isWide: true),
|
||
],
|
||
ActiveAgent.consultation: [],
|
||
ActiveAgent.report: [
|
||
_AgentAction(label: '上传报告', icon: Icons.upload_file_outlined, isWide: true, route: 'reports'),
|
||
_AgentAction(label: '查看历史', icon: Icons.history_outlined, isWide: true, route: 'reports'),
|
||
],
|
||
ActiveAgent.exercise: [
|
||
_AgentAction(label: '查看计划', icon: Icons.calendar_month_outlined, isWide: true, route: 'exercisePlan'),
|
||
_AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true, action: 'createPlan'),
|
||
_AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true, action: 'checkIn'),
|
||
],
|
||
};
|
||
|
||
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(0xFF8B9CF7)),
|
||
const SizedBox(width: 6),
|
||
const Text('AI 建议', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7))),
|
||
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)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
}
|