Files
AI-Health/health_app/lib/pages/home/widgets/chat_messages_view.dart
MingNian 20a5ce1199 fix: 数据确认面板修复 - 显示实际数值+确认关闭+侧边栏刷新+清理旧数据
- Handler返回value/unit/isAbnormal到metadata
- DataConfirmCard显示实际数值和单位
- 确认按钮关闭卡片+刷新健康概览
- 健康Agent欢迎卡简化为'查看趋势'
- 清空87条旧健康测试数据
2026-06-04 21:57:02 +08:00

1430 lines
65 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 '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, ref, msg);
case MessageType.medicationConfirm:
return _buildMedicationConfirmCard(context, msg);
case MessageType.dietAnalysis:
return _buildDietAnalysisCard(context, msg);
case MessageType.reportAnalysis:
return _buildReportAnalysisCard(context, msg);
case MessageType.onboarding:
return _buildOnboardingCard(context, ref);
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.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, WidgetRef ref, ChatMessage msg) {
if (msg.confirmed) return const SizedBox.shrink();
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);
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(0xFF6C5CE7).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))),
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)))])),
],
const SizedBox(height: 18),
Row(children: [
Expanded(child: _cardFilledBtn('确认', Icons.check, onTap: () {
msg.confirmed = true;
ref.invalidate(latestHealthProvider);
final notifier = ref.read(chatProvider.notifier);
notifier.markNeedsRebuild();
})),
const SizedBox(width: 8),
Expanded(child: _cardOutlineBtn('查看详情', Icons.trending_up_outlined, onTap: () => pushRoute(ref, 'trend', params: {'type': metricType}))),
]),
])),
]),
),
);
}
// ═══════════════════════════════════════════════════════════
// 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. OnboardingCard — 首次建档引导
Widget _buildOnboardingCard(BuildContext context, WidgetRef ref) {
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: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(25), blurRadius: 14, offset: const Offset(0, 4))],
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Color(0xFF8B9CF7), Color(0xFFA78BFA)], begin: Alignment.topLeft, end: Alignment.bottomRight),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(width: 48, height: 48, decoration: BoxDecoration(color: Colors.white.withAlpha(30), borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.health_and_safety, size: 28, color: Colors.white)),
const SizedBox(width: 14),
const Text('欢迎来到健康管家!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white)),
]),
const SizedBox(height: 12),
Text('我是您的AI健康管家。为了给您更精准的建议\n我先了解一些基本信息好吗大约2-3分钟。',
style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(220), height: 1.5)),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
child: Column(children: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
ref.read(chatProvider.notifier).startOnboarding();
},
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)),
child: const Text('开始建档', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 10),
TextButton(
onPressed: () => ref.read(chatProvider.notifier).dismissOnboarding(),
child: const Text('以后再说', style: TextStyle(fontSize: 14, color: Color(0xFF999999)))),
]),
),
],
),
),
);
}
// 7. 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, {VoidCallback? onTap}) {
return ElevatedButton(
onPressed: onTap ?? () {},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C5CE7),
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, {VoidCallback? onTap}) {
return OutlinedButton(
onPressed: onTap ?? () {},
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF6C5CE7),
side: const BorderSide(color: Color(0xFF6C5CE7), 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 _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 plans = ref.read(exerciseServiceProvider).getPlans();
plans.then((list) {
for (final p in list) {
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = now.weekday - 1;
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['dayOfWeek'] == today, orElse: () => null);
if (todayItem != null) {
final done = todayItem['isCompleted'] == true;
final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30';
tasks.add(_taskRow(context, Icons.directions_run,
'$name $dur分钟',
status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'),
onTap: () => pushRoute(ref, 'exercisePlan'),
));
break;
}
}
});
// 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.trending_up, isWide: true, 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, route: 'exerciseCreate'),
],
};
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)),
],
],
),
),
);
}
}