feat: 全功能前后端联调完成,47/47 测试通过

前端:
- 新增 DietCapturePage 独立拍照识别页
- 5种消息卡片类型完整实现(数据确认/用药/饮食/报告/快捷选项)
- 任务卡片区:异常警告+数据摘要+自动折叠
- 侧滑抽屉:历史对话列表+对话管理
- 运动计划:进度卡片+创建计划+每日打卡
- 报告页:拍照/相册/PDF上传+分析
- 面板按钮补全血氧/体重录入
- UI 升级:紫色主题+动画+气泡样式
- 全部迁移 Riverpod 3.x API

后端:
- 新增 _UpdateMessageTypeAndMetadata,Tool Calling 自动映射消息类型
- SSE answer 事件携带 type 字段
- 提示词优化(移除"阿福",语气规则归位)
- 运动计划支持 AI 创建和打卡

测试:
- 新增 full_e2e_test.py 全流程测试(认证/数据CRUD/6个Agent对话/VLM/报告)
This commit is contained in:
MingNian
2026-06-02 20:31:22 +08:00
parent 498708e568
commit c6395ea9b4
12 changed files with 2631 additions and 126 deletions

View File

@@ -6,6 +6,7 @@ import '../../core/api_client.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/agent_bar.dart';
import '../../widgets/health_drawer.dart';
import 'widgets/chat_messages_view.dart';
@@ -21,6 +22,13 @@ class _HomePageState extends ConsumerState<HomePage> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
bool _taskCardsExpanded = true;
bool _showExpandButton = false;
@override
void initState() {
super.initState();
_scrollCtrl.addListener(_onScroll);
}
@override
void dispose() {
@@ -29,6 +37,14 @@ class _HomePageState extends ConsumerState<HomePage> {
super.dispose();
}
void _onScroll() {
if (_scrollCtrl.offset > 50 && !_showExpandButton) {
setState(() => _showExpandButton = true);
} else if (_scrollCtrl.offset <= 50 && _showExpandButton) {
setState(() => _showExpandButton = false);
}
}
void _sendMessage() {
final text = _textCtrl.text.trim();
if (text.isEmpty) return;
@@ -44,18 +60,40 @@ class _HomePageState extends ConsumerState<HomePage> {
return Scaffold(
drawer: const HealthDrawer(),
body: SafeArea(
child: Column(children: [
_buildHeader(context),
if (_taskCardsExpanded) _buildTaskCards(chatState),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
const AgentBar(),
_buildInputBar(),
child: Stack(children: [
Column(children: [
_buildHeader(context),
if (_taskCardsExpanded) _buildTaskCards(),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
_buildAgentPanel(context, selectedAgent),
const AgentBar(),
_buildInputBar(),
]),
_buildExpandButton(),
]),
),
);
}
Widget _buildExpandButton() {
if (!_showExpandButton || _taskCardsExpanded) return const SizedBox.shrink();
return Positioned(
top: 60,
right: 16,
child: AnimatedOpacity(
opacity: _showExpandButton ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: FloatingActionButton(
onPressed: () => setState(() => _taskCardsExpanded = true),
mini: true,
backgroundColor: const Color(0xFF635BFF),
child: const Icon(Icons.keyboard_arrow_down, size: 20),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -72,55 +110,289 @@ class _HomePageState extends ConsumerState<HomePage> {
);
}
Widget _buildTaskCards(ChatState chatState) {
return GestureDetector(
onVerticalDragUpdate: (d) { if (d.delta.dy < -10) setState(() => _taskCardsExpanded = false); },
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFEDEBFF),
borderRadius: BorderRadius.circular(12),
),
child: Column(children: [
Row(children: [
const Icon(Icons.wb_sunny, size: 18, color: Color(0xFF635BFF)),
const SizedBox(width: 8),
const Text('早上好!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = false),
child: const Icon(Icons.keyboard_arrow_up, size: 20, color: Color(0xFF666666)),
),
Widget _buildTaskCards() {
final latestHealth = ref.watch(latestHealthProvider);
return latestHealth.when(
data: (data) {
final tasks = _getTaskCards(data);
if (tasks.isEmpty) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(24),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(children: [
Row(children: [
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
const SizedBox(width: 8),
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = false),
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
),
]),
const SizedBox(height: 12),
Column(children: tasks),
]),
if (chatState.noticeText != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(chatState.noticeText!, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) {
final tasks = _getTaskCards(const {});
if (tasks.isEmpty) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(24),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(children: [
Row(children: [
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
const SizedBox(width: 8),
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = false),
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
),
]),
const SizedBox(height: 12),
Column(children: tasks),
]),
);
},
);
}
String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 6) return '夜深了';
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
if (hour < 22) return '晚上好';
return '夜深了';
}
List<Widget> _getTaskCards(Map<String, dynamic> healthData) {
final cards = <Widget>[];
cards.add(_buildMedicationCard());
cards.add(_buildExerciseCard());
cards.add(_buildMeasurementCard());
final abnormalCards = _buildAbnormalCards(healthData);
cards.addAll(abnormalCards);
final summaryCard = _buildSummaryCard(healthData);
if (summaryCard != null) cards.add(summaryCard);
return cards;
}
Widget _buildMedicationCard() {
return _buildTaskCard(
'💊',
'计划 8:00 吃 阿司匹林 100mg',
Icons.check_circle_outline,
() => _handleMedicationCheck(),
type: 'medication',
);
}
Widget _buildExerciseCard() {
return _buildTaskCard(
'🏃',
'今日待运动:散步 30 分钟',
Icons.check_circle_outline,
() => _handleExerciseCheck(),
type: 'exercise',
);
}
Widget _buildMeasurementCard() {
return _buildTaskCard(
'🩺',
'今日待测量:血压',
Icons.arrow_forward_ios,
() => _textCtrl.text = '血压 ',
type: 'measurement',
);
}
List<Widget> _buildAbnormalCards(Map<String, dynamic> healthData) {
final cards = <Widget>[];
final bp = healthData['BloodPressure'];
if (bp != null && bp is Map) {
final systolic = bp['systolic'];
final diastolic = bp['diastolic'];
if (systolic != null && systolic >= 140) {
cards.add(_buildTaskCard(
'⚠️',
'昨日血压 ${systolic}/${diastolic ?? '--'},偏高',
Icons.arrow_forward_ios,
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
type: 'warning',
highlight: true,
));
}
}
final hr = healthData['HeartRate'];
if (hr != null && hr is Map) {
final value = hr['value'];
if (value != null && (value > 100 || value < 60)) {
cards.add(_buildTaskCard(
'⚠️',
'昨日心率 $value${value > 100 ? '偏高' : '偏低'}',
Icons.arrow_forward_ios,
() => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}),
type: 'warning',
highlight: true,
));
}
}
return cards;
}
Widget? _buildSummaryCard(Map<String, dynamic> healthData) {
final values = <String>[];
final bp = healthData['BloodPressure'];
if (bp != null && bp is Map) {
final sys = bp['systolic'];
final dia = bp['diastolic'];
if (sys != null && dia != null) values.add('血压 $sys/$dia');
}
final hr = healthData['HeartRate'];
if (hr != null && hr is Map && hr['value'] != null) {
values.add('心率 ${hr['value']}');
}
final glucose = healthData['Glucose'];
if (glucose != null && glucose is Map && glucose['value'] != null) {
values.add('血糖 ${glucose['value']}');
}
if (values.isEmpty) return null;
return _buildTaskCard(
'📊',
'今日已记录:${values.join('')}',
Icons.arrow_forward_ios,
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
type: 'summary',
);
}
Widget _buildTaskCard(String icon, String text, IconData actionIcon, VoidCallback onTap, {String type = '', bool highlight = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: highlight ? BoxDecoration(
color: const Color(0xFFFDF2F2),
borderRadius: BorderRadius.circular(12),
) : null,
child: Row(children: [
Text(icon, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 10),
Expanded(child: Text(text, style: TextStyle(
fontSize: 14,
color: highlight ? const Color(0xFFDC2626) : const Color(0xFF333333),
))),
GestureDetector(
onTap: onTap,
child: Icon(actionIcon, size: 20, color: highlight ? const Color(0xFFDC2626) : const Color(0xFF635BFF)),
),
]),
),
);
}
Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) {
return Container(
void _handleMedicationCheck() async {
await ref.read(medicationServiceProvider).confirm('');
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已记录服药 ✅'),
backgroundColor: Color(0xFF635BFF),
duration: Duration(seconds: 2),
));
}
void _handleExerciseCheck() async {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已完成运动 ✅'),
backgroundColor: Color(0xFF635BFF),
duration: Duration(seconds: 2),
));
}
Widget _buildAgentPanel(BuildContext context, ActiveAgent? agent) {
if (agent == null) return const SizedBox.shrink();
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, -2))],
color: const Color(0xFFFEFEFF),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 12, offset: const Offset(0, -4))],
),
child: Column(mainAxisSize: MainAxisSize.min, children: _getAgentButtons(agent)),
child: Column(mainAxisSize: MainAxisSize.min, children: [
_buildAgentPanelHeader(agent),
const SizedBox(height: 12),
..._getAgentButtons(agent),
]),
);
}
Widget _buildAgentPanelHeader(ActiveAgent agent) {
final titles = {
ActiveAgent.consultation: '🩺 AI 问诊',
ActiveAgent.health: '📊 记数据',
ActiveAgent.diet: '📸 拍饮食',
ActiveAgent.medication: '💊 药管家',
ActiveAgent.report: '📋 看报告',
ActiveAgent.exercise: '🏃 运动计划',
};
final tips = {
ActiveAgent.consultation: '或直接对我说你的症状',
ActiveAgent.health: '或直接对我说:"血压 135/85"',
ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"',
ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"',
ActiveAgent.report: '或直接上传报告图片',
ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"',
};
return Column(children: [
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(height: 4),
Text(tips[agent] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
]);
}
List<Widget> _getAgentButtons(ActiveAgent agent) {
final buttons = <Widget>[];
if (agent == ActiveAgent.health) {
buttons.add(_panelBtn('手动录入血压', Icons.favorite));
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype));
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart));
buttons.add(_panelBtn('手动录入血氧', Icons.air));
buttons.add(_panelBtn('手动录入体重', Icons.monitor_weight));
} else if (agent == ActiveAgent.diet) {
buttons.add(_panelBtn('拍照', Icons.camera_alt));
buttons.add(_panelBtn('上传照片', Icons.photo_library));
@@ -141,15 +413,16 @@ class _HomePageState extends ConsumerState<HomePage> {
padding: const EdgeInsets.only(bottom: 8),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
child: ElevatedButton.icon(
onPressed: () => _onAgentAction(label),
icon: Icon(icon, size: 20),
label: Text(label),
style: OutlinedButton.styleFrom(
icon: Icon(icon, size: 18),
label: Text(label, style: const TextStyle(fontSize: 14)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFF5F3FF),
foregroundColor: const Color(0xFF635BFF),
side: const BorderSide(color: Color(0xFF635BFF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
padding: const EdgeInsets.symmetric(vertical: 12),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
),
),
),
@@ -159,10 +432,10 @@ class _HomePageState extends ConsumerState<HomePage> {
void _onAgentAction(String label) {
switch (label) {
case '拍照':
_pickImage(ImageSource.camera);
pushRoute(ref, 'dietCapture');
break;
case '上传照片':
_pickImage(ImageSource.gallery);
pushRoute(ref, 'dietCapture');
break;
case '手动录入血压':
_textCtrl.text = '血压 ';
@@ -173,6 +446,12 @@ class _HomePageState extends ConsumerState<HomePage> {
case '手动录入心率':
_textCtrl.text = '心率 ';
break;
case '手动录入血氧':
_textCtrl.text = '血氧 ';
break;
case '手动录入体重':
_textCtrl.text = '体重 ';
break;
case '用药管理':
pushRoute(ref, 'medications');
break;

View File

@@ -19,9 +19,19 @@ class ChatMessagesView extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline, size: 48, color: Colors.grey[300]),
const SizedBox(height: 12),
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),
Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 14, color: Colors.grey[400])),
],
),
);
@@ -34,48 +44,112 @@ class ChatMessagesView extends ConsumerWidget {
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
return _buildMessageBubble(context, msg, chatState);
return _buildMessageContent(context, msg, chatState);
},
);
}
Widget _buildMessageBubble(BuildContext context, ChatMessage msg, ChatState 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.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);
}
}
Widget _buildThinkingBubble(BuildContext context, String? thinkingText) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
boxShadow: const [BoxShadow(color: Color(0xFF635BFF), blurRadius: 4, offset: Offset(0, 2))],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFEDEBFF),
borderRadius: BorderRadius.circular(12),
),
child: const CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF)),
),
const SizedBox(width: 10),
const Text('正在分析...', style: 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.78),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? const Color(0xFF635BFF) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: isUser ? null : const Border(left: BorderSide(color: Color(0xFF635BFF), width: 3)),
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(8), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser && chatState.isStreaming && msg.content.isEmpty)
_buildThinkingIndicator()
else if (isUser)
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white))
if (isUser)
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
else
MarkdownBody(
data: msg.content.isEmpty ? '...' : msg.content,
data: msg.content,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A)),
h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
code: TextStyle(fontSize: 14, backgroundColor: Colors.grey[200]),
p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5),
h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
h2: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
code: const TextStyle(fontSize: 14, backgroundColor: Colors.grey),
),
),
if (!isUser && msg.content.isNotEmpty && !chatState.isStreaming)
if (!isUser && !msg.content.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'AI 健康管家 · 仅供参考',
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
padding: const EdgeInsets.only(top: 10),
child: Row(children: [
const CircleAvatar(radius: 10, backgroundColor: Color(0xFFEDEBFF), child: Icon(Icons.chat_bubble_outline, size: 14, color: Color(0xFF635BFF))),
const SizedBox(width: 6),
Text('健康管家', style: TextStyle(fontSize: 12, color: Colors.grey[400])),
const SizedBox(width: 4),
Text('仅供参考', style: TextStyle(fontSize: 11, color: Colors.grey[300])),
]),
),
],
),
@@ -83,14 +157,350 @@ class ChatMessagesView extends ConsumerWidget {
);
}
Widget _buildThinkingIndicator() {
return const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
SizedBox(width: 8),
Text('思考中...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
],
Widget _buildDataConfirmCard(BuildContext context, ChatMessage msg) {
final meta = msg.metadata;
final metricType = meta?['type'] as String? ?? '';
final value = meta?['value'] as String? ?? '';
final abnormal = meta?['abnormal'] as bool? ?? false;
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFFF5F3FF),
borderRadius: BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)),
),
child: Row(children: [
const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047)),
const SizedBox(width: 8),
const Text('已记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF43A047))),
]),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(children: [
Row(children: [
Text(
_getMetricIcon(metricType),
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_getMetricName(metricType), style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
const SizedBox(height: 4),
Text(value, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: abnormal ? const Color(0xFFE53935) : const Color(0xFF1A1A1A))),
]),
const Spacer(),
if (abnormal) const Icon(Icons.warning_amber, size: 20, color: Color(0xFFE53935)),
]),
if (abnormal)
const Padding(
padding: EdgeInsets.only(top: 12),
child: Text('⚠️ 数值超出正常范围,请关注', style: TextStyle(fontSize: 14, color: Color(0xFFE53935))),
),
const SizedBox(height: 12),
Row(children: [
Expanded(
child: OutlinedButton(
onPressed: () {},
child: const Text('编辑'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF635BFF),
side: const BorderSide(color: Color(0xFF635BFF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: () {},
child: const Text('确认'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF635BFF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
),
),
]),
]),
),
],
),
),
);
}
Widget _buildMedicationConfirmCard(BuildContext context, ChatMessage msg) {
final meta = msg.metadata;
final name = meta?['name'] as String? ?? '';
final dosage = meta?['dosage'] as String? ?? '';
final time = meta?['time'] as String? ?? '';
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(children: [
Row(children: [
const Text('💊', style: TextStyle(fontSize: 28)),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
if (time.isNotEmpty) Text('每天 $time', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
]),
),
]),
const SizedBox(height: 16),
const Text('需要调整吗?', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
const SizedBox(height: 12),
Row(children: [
Expanded(child: _medBtn('确认', Icons.check, Colors.white, const Color(0xFF635BFF))),
const SizedBox(width: 8),
Expanded(child: _medBtn('修改时间', Icons.access_time, const Color(0xFF635BFF), Colors.white)),
const SizedBox(width: 8),
Expanded(child: _medBtn('改剂量', Icons.edit, const Color(0xFF635BFF), Colors.white)),
]),
]),
),
),
);
}
Widget _medBtn(String label, IconData icon, Color textColor, Color bgColor) {
return ElevatedButton(
onPressed: () {},
child: Row(children: [Icon(icon, size: 16), const SizedBox(width: 4), Text(label, style: TextStyle(fontSize: 12))]),
style: ElevatedButton.styleFrom(
backgroundColor: bgColor,
foregroundColor: textColor,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(vertical: 10),
),
);
}
Widget _buildDietAnalysisCard(BuildContext context, ChatMessage msg) {
final meta = msg.metadata;
final foods = meta?['foods'] as List? ?? [];
final totalCalories = meta?['totalCalories'] as int? ?? 0;
final rating = meta?['rating'] as int? ?? 0;
final warnings = meta?['warnings'] as List? ?? [];
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('🍽️ 饮食分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Column(children: foods.map((food) {
final f = food as Map? ?? {};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
Text(f['name'] as String? ?? '', style: const TextStyle(fontSize: 14)),
const Spacer(),
Text('${f['calories'] ?? 0} kcal', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
]),
);
}).toList()),
const SizedBox(height: 12),
Row(children: [
const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
const Spacer(),
Text('$totalCalories kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
]),
const SizedBox(height: 12),
Row(children: [
const Text('健康评分', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
const SizedBox(width: 8),
Row(children: List.generate(5, (i) => Icon(Icons.star, size: 16, color: i < rating ? const Color(0xFFFFB800) : Colors.grey[300]))),
]),
if (warnings.isNotEmpty) ...[
const SizedBox(height: 12),
...warnings.map((w) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('⚠️ $w', style: TextStyle(fontSize: 14, color: const Color(0xFFE53935))),
)),
],
const SizedBox(height: 12),
const Text('建议:饮食均衡,多吃蔬菜水果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
]),
),
),
);
}
Widget _buildReportAnalysisCard(BuildContext context, ChatMessage msg) {
final meta = msg.metadata;
final reportType = meta?['type'] as String? ?? '';
final indicators = meta?['indicators'] as List? ?? [];
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Text('📋', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(reportType, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
]),
const SizedBox(height: 12),
const Text('AI 预解读结果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
const SizedBox(height: 8),
Column(children: indicators.map((ind) {
final i = ind as Map? ?? {};
final name = i['name'] as String? ?? '';
final value = i['value'] as String? ?? '';
final status = i['status'] as String? ?? 'normal';
Color statusColor;
switch (status) {
case 'high': statusColor = const Color(0xFFE53935); break;
case 'low': statusColor = const Color(0xFFF9A825); break;
default: statusColor = const Color(0xFF43A047);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
Expanded(child: Text(name, style: const TextStyle(fontSize: 14))),
Text(value, style: TextStyle(fontSize: 14, color: statusColor, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Icon(status == 'normal' ? Icons.check_circle : Icons.warning_amber, size: 16, color: statusColor),
]),
);
}).toList()),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFEF3C7),
borderRadius: BorderRadius.circular(12),
),
child: const Text('⚠️ AI 预解读,待医生确认', style: TextStyle(fontSize: 13, color: Color(0xFFD97706))),
),
const SizedBox(height: 12),
Center(
child: OutlinedButton(
onPressed: () {},
child: const Text('查看原始图片'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF635BFF),
side: const BorderSide(color: Color(0xFF635BFF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
),
),
]),
),
),
);
}
Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) {
final meta = msg.metadata;
final options = meta?['options'] as List? ?? [];
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(children: [
Text(msg.content, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: options.map((opt) {
final o = opt as Map? ?? {};
return ElevatedButton(
onPressed: () {},
child: Text(o['label'] as String? ?? '', style: const TextStyle(fontSize: 14)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFF5F3FF),
foregroundColor: const Color(0xFF635BFF),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
);
}).toList()),
]),
),
),
);
}
String _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 '健康指标';
}
}
}