- sendImage: 本地预览→上传→远程URL替换 - doctorListProvider: 8s超时+mock医生fallback - currentExercisePlanProvider: 8s超时→显示空状态 - 用药编辑: try-catch防黑屏+刷新列表 - 服药打卡: 接入后端confirm()接口
1258 lines
57 KiB
Dart
1258 lines
57 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(0xFFEDEBFF),
|
||
borderRadius: BorderRadius.circular(40),
|
||
),
|
||
child: const Icon(Icons.health_and_safety, size: 40, color: Color(0xFF635BFF)),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text('开始和 AI 健康管家对话吧', style: Theme.of(context).textTheme.bodyMedium),
|
||
const SizedBox(height: 8),
|
||
const Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 14, color: Color(0xFF9E9E9E))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return ListView.builder(
|
||
controller: scrollCtrl,
|
||
reverse: true,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
itemCount: messages.length,
|
||
itemBuilder: (context, index) {
|
||
final msg = messages[messages.length - 1 - index];
|
||
return _buildMessageContent(context, ref, msg, chatState);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ─── 消息分发 ─────────────────────────────────────────────
|
||
|
||
Widget _buildMessageContent(BuildContext context, WidgetRef ref, ChatMessage msg, ChatState chatState) {
|
||
final isUser = msg.isUser;
|
||
|
||
if (!isUser && chatState.isStreaming && msg.content.isEmpty) {
|
||
return _buildThinkingBubble(context, chatState.thinkingText);
|
||
}
|
||
|
||
switch (msg.type) {
|
||
case MessageType.agentWelcome:
|
||
return _buildAgentWelcomeCard(context, ref, msg, chatState.activeAgent);
|
||
case MessageType.dataConfirm:
|
||
return _buildDataConfirmCard(context, msg);
|
||
case MessageType.medicationConfirm:
|
||
return _buildMedicationConfirmCard(context, msg);
|
||
case MessageType.dietAnalysis:
|
||
return _buildDietAnalysisCard(context, msg);
|
||
case MessageType.reportAnalysis:
|
||
return _buildReportAnalysisCard(context, msg);
|
||
case MessageType.quickOptions:
|
||
return _buildQuickOptionsCard(context, msg);
|
||
default:
|
||
return _buildTextBubble(context, msg);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 1. AgentWelcomeCard — 智能体欢迎卡片
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _buildAgentWelcomeCard(BuildContext context, WidgetRef ref, ChatMessage msg, ActiveAgent agent) {
|
||
final info = _agentInfo(agent);
|
||
final actions = agent.actions;
|
||
final screenWidth = MediaQuery.of(context).size.width;
|
||
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
constraints: BoxConstraints(maxWidth: screenWidth * 0.92),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFFFFFF),
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [
|
||
BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 16, offset: const Offset(0, 4)),
|
||
],
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── 紫色渐变头部 ──
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(20, 24, 16, 20),
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [Color(0xFF7C73FF), Color(0xFF635BFF), Color(0xFF5241D9)],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withAlpha(30),
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Icon(info.$1, size: 26, color: Colors.white),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(info.$2, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white)),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withAlpha(25),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Text(info.$3, style: const TextStyle(fontSize: 12, color: Color(0xFFE0DDFF))),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () {},
|
||
child: Container(
|
||
width: 28,
|
||
height: 28,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withAlpha(20),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(Icons.close, size: 16, color: Color(0xFFE0DDFF)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// ── 快捷操作按钮网格 ──
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
|
||
child: Wrap(
|
||
spacing: 10,
|
||
runSpacing: 10,
|
||
children: agent == ActiveAgent.consultation
|
||
? _buildDoctorCards(screenWidth, ref)
|
||
: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref)).toList(),
|
||
),
|
||
),
|
||
|
||
// ── 底部提示 ──
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 18),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Container(width: 24, height: 1, color: const Color(0xFFD0CCED)),
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||
child: Text('或直接对我说...', style: TextStyle(fontSize: 13, color: Color(0xFF9E94CF))),
|
||
),
|
||
Container(width: 24, height: 1, color: const Color(0xFFD0CCED)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref) {
|
||
return InkWell(
|
||
onTap: () {
|
||
if (a.route != null) {
|
||
if (a.route == 'camera' || a.route == 'gallery') {
|
||
ref.read(cameraActionProvider.notifier).trigger(a.route!);
|
||
} else {
|
||
pushRoute(ref, a.route!);
|
||
}
|
||
} else if (a.label == '服药打卡') {
|
||
_medicationCheckIn(ref, context);
|
||
}
|
||
}, borderRadius: BorderRadius.circular(14),
|
||
child: Container(
|
||
width: ((screenWidth - 72) / (a.isWide ? 2 : 3)) - 10,
|
||
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 8),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF7F5FF),
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: const Color(0xFFEBE8FF), width: 1),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFEDEAFF),
|
||
borderRadius: BorderRadius.circular(11),
|
||
),
|
||
child: Icon(a.icon, size: 20, color: const Color(0xFF635BFF)),
|
||
),
|
||
const SizedBox(height: 7),
|
||
Text(a.label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF333333))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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(0xFFEDEBFF)),
|
||
),
|
||
child: Column(children: [
|
||
CircleAvatar(
|
||
radius: 24,
|
||
backgroundColor: const Color(0xFFEDEBFF),
|
||
child: Text(doc['name']![0], style: const TextStyle(fontSize: 20, color: Color(0xFF635BFF))),
|
||
),
|
||
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(0xFFF5F3FF),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(doc['dept']!, style: const TextStyle(fontSize: 10, color: Color(0xFF635BFF))),
|
||
),
|
||
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(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))],
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── 绿色勾选条 ──
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(colors: [Color(0xFF4CAF50), Color(0xFF43A047)]),
|
||
),
|
||
child: const Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.check_circle, size: 18, color: Colors.white),
|
||
SizedBox(width: 6),
|
||
Text('✓ 数据已记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)),
|
||
],
|
||
),
|
||
),
|
||
|
||
Padding(
|
||
padding: const EdgeInsets.all(18),
|
||
child: Column(
|
||
children: [
|
||
// 记录时间
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(recordTime.isNotEmpty ? recordTime : _formatTime(msg.createdAt), style: const TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
|
||
),
|
||
const SizedBox(height: 14),
|
||
|
||
// 主要指标区域
|
||
Container(
|
||
padding: const EdgeInsets.all(18),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF9F8FF),
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 52,
|
||
height: 52,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFEDEAFF),
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Center(child: Text(_getMetricIcon(metricType), style: const TextStyle(fontSize: 26))),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(_getMetricName(metricType), style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||
const SizedBox(height: 4),
|
||
RichText(
|
||
text: TextSpan(
|
||
children: [
|
||
TextSpan(text: value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: abnormal ? const Color(0xFFE53935) : const Color(0xFF1A1A2E))),
|
||
TextSpan(text: ' $unit', style: TextStyle(fontSize: 14, color: abnormal ? const Color(0xFFE53970) : const Color(0xFF999999))),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 异常警告条
|
||
if (abnormal) ...[
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFFF3F0),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: const Color(0xFFFFDAD4), width: 1),
|
||
),
|
||
child: const Row(
|
||
children: [
|
||
Icon(Icons.warning_amber_rounded, size: 18, color: Color(0xFFE53935)),
|
||
SizedBox(width: 8),
|
||
Expanded(child: Text('⚠️ 数值偏高,建议关注', style: TextStyle(fontSize: 13, color: Color(0xFFE53935), fontWeight: FontWeight.w500))),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
// 迷你趋势图(最近3次)
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
children: [
|
||
const Text('近期趋势', style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))),
|
||
const Spacer(),
|
||
const Text('最近3次', style: TextStyle(fontSize: 11, color: Color(0xFFCCCCCC))),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
height: 36,
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: trend.asMap().entries.map((e) {
|
||
final h = (e.value * 32).clamp(6.0, 32.0);
|
||
return Padding(
|
||
padding: EdgeInsets.only(right: e.key < trend.length - 1 ? 10 : 0),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 22,
|
||
height: h,
|
||
decoration: BoxDecoration(
|
||
color: e.key == trend.length - 1 ? const Color(0xFF635BFF) : const Color(0xFFD5D0FF),
|
||
borderRadius: BorderRadius.circular(5),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text('${e.key + 1}', style: const TextStyle(fontSize: 9, color: Color(0xFFBBBBBB))),
|
||
],
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
|
||
// 底部操作按钮
|
||
const SizedBox(height: 18),
|
||
Row(children: [
|
||
Expanded(child: _cardOutlineBtn('编辑', Icons.edit_outlined)),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: _cardFilledBtn('确认', Icons.check)),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: _cardOutlineBtn('查看详情', Icons.trending_up_outlined)),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 3. MedicationConfirmCard — 增强版用药确认卡片
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _buildMedicationConfirmCard(BuildContext context, ChatMessage msg) {
|
||
final meta = msg.metadata;
|
||
final name = meta?['name'] as String? ?? '';
|
||
final dosage = meta?['dosage'] as String? ?? '';
|
||
final time = meta?['time'] as String? ?? '';
|
||
final frequency = meta?['frequency'] as String? ?? '';
|
||
final remaining = meta?['remaining'] as double? ?? 0.65;
|
||
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFFFFFF),
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))],
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── 药品头部 ──
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(18, 20, 18, 16),
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(colors: [Color(0xFFE8F0FE), Color(0xFFF5F3FF)]),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 46,
|
||
height: 46,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFFF3E0),
|
||
borderRadius: BorderRadius.circular(13),
|
||
),
|
||
child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))),
|
||
if (dosage.isNotEmpty) ...[
|
||
const SizedBox(height: 3),
|
||
Text(dosage, style: const TextStyle(fontSize: 13, color: Color(0xFF777777))),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 6),
|
||
child: Column(
|
||
children: [
|
||
// 服药时间 & 频率
|
||
_medInfoRow(Icons.schedule_outlined, time.isNotEmpty ? '服药时间:$time' : '待设置'),
|
||
if (frequency.isNotEmpty) _medInfoRow(Icons.repeat, '频率:$frequency'),
|
||
|
||
// 剩余药量进度条
|
||
const SizedBox(height: 14),
|
||
Row(
|
||
children: [
|
||
const Text('剩余药量', style: TextStyle(fontSize: 13, color: Color(0xFF666666))),
|
||
const Spacer(),
|
||
Text('${(remaining * 100).toInt()}%', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: LinearProgressIndicator(
|
||
value: remaining,
|
||
minHeight: 8,
|
||
backgroundColor: const Color(0xFFEFEDFF),
|
||
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF635BFF)),
|
||
),
|
||
),
|
||
|
||
// 操作按钮
|
||
const SizedBox(height: 18),
|
||
Row(children: [
|
||
Expanded(child: _cardFilledBtn('确认服药', Icons.check_circle_outline)),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: _cardOutlineBtn('跳过', Icons.skip_next)),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: _cardOutlineBtn('设置提醒', Icons.notifications_none_outlined)),
|
||
]),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _medInfoRow(IconData icon, String text) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 17, color: const Color(0xFF888888)),
|
||
const SizedBox(width: 8),
|
||
Text(text, style: const TextStyle(fontSize: 13, color: Color(0xFF444444))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 4. DietAnalysisCard — 增强版饮食分析卡片
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _buildDietAnalysisCard(BuildContext context, ChatMessage msg) {
|
||
final meta = msg.metadata;
|
||
final foods = meta?['foods'] as List? ?? [];
|
||
final totalCalories = meta?['totalCalories'] as int? ?? 0;
|
||
final advice = meta?['advice'] as String? ?? '饮食均衡,多吃蔬菜水果,减少高油高糖食物摄入。';
|
||
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFFFFFF),
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))],
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── 头部 ──
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFFFF8E1), Color(0xFFFFF3E0)])),
|
||
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Text('🍽️ ', style: TextStyle(fontSize: 18)),
|
||
Text('饮食分析结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))),
|
||
]),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
// ── 总热量(仅 >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]))),
|
||
]),
|
||
);
|
||
}),
|
||
] else ...[
|
||
Container(
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(12)),
|
||
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(Icons.hourglass_empty, size: 18, color: Color(0xFF999999)),
|
||
SizedBox(width: 8),
|
||
Text('正在分析食物中...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||
]),
|
||
),
|
||
],
|
||
|
||
// ── 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(0xFFF5F3FF), 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(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))],
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── 报告头部 ──
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 14),
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(colors: [Color(0xFFE8EAF6), Color(0xFFEDE7F6)]),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFC5CAE9),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: const Icon(Icons.description_outlined, size: 20, color: Color(0xFF3F51B5)),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(reportType, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))),
|
||
if (reportDate.isNotEmpty)
|
||
Text(reportDate, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 指标表格
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFAFAFC),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: const Color(0xFFEEEEEE), width: 1),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// 表头
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: const BoxDecoration(
|
||
color: Color(0xFFF5F4FA),
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||
),
|
||
child: const Row(
|
||
children: [
|
||
Expanded(flex: 2, child: Text('指标名称', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF666666)))),
|
||
Expanded(flex: 1, child: Text('数值', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF666666)), textAlign: TextAlign.center)),
|
||
Expanded(flex: 1, child: Text('状态', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF666666)), textAlign: TextAlign.center)),
|
||
],
|
||
),
|
||
),
|
||
// 数据行
|
||
...indicators.map((ind) {
|
||
final i = ind as Map? ?? {};
|
||
final name = i['name'] as String? ?? '';
|
||
final value = i['value'] as String? ?? '';
|
||
final status = i['status'] as String? ?? 'normal';
|
||
final refRange = i['refRange'] as String? ?? '';
|
||
final isAbnormal = status != 'normal';
|
||
Color sc;
|
||
switch (status) {
|
||
case 'high': sc = const Color(0xFFE53935); break;
|
||
case 'low': sc = const Color(0xFFF9A825); break;
|
||
default: sc = const Color(0xFF43A047);
|
||
}
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: isAbnormal ? const Color(0xFFFFF8F5) : Colors.transparent,
|
||
border: Border(
|
||
bottom: BorderSide(color: const Color(0xFFF0F0F0), width: 0.5),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
flex: 2,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(name, style: TextStyle(fontSize: 13, color: isAbnormal ? const Color(0xFFE53935) : const Color(0xFF333333), fontWeight: isAbnormal ? FontWeight.w600 : FontWeight.normal)),
|
||
if (refRange.isNotEmpty) Text('参考:$refRange', style: const TextStyle(fontSize: 10, color: Color(0xFFAAAAAA))),
|
||
],
|
||
),
|
||
),
|
||
Expanded(flex: 1, child: Text(value, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: sc))),
|
||
Expanded(
|
||
flex: 1,
|
||
child: Center(
|
||
child: Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(shape: BoxShape.circle, color: sc),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
|
||
// AI 解读摘要
|
||
if (summary.isNotEmpty) ...[
|
||
const SizedBox(height: 14),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF3EFFF),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: const Color(0xFFDDD8FF), width: 0.8),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Row(
|
||
children: [
|
||
Icon(Icons.auto_awesome, size: 16, color: Color(0xFF635BFF)),
|
||
SizedBox(width: 6),
|
||
Text('AI 解读摘要', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(summary, style: const TextStyle(fontSize: 13, color: Color(0xFF555555), height: 1.5)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
// 查看完整解读按钮
|
||
const SizedBox(height: 14),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: _cardFilledBtn('查看完整解读', Icons.article_outlined),
|
||
),
|
||
const SizedBox(height: 4),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 6. QuickOptionsCard — 优化样式
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) {
|
||
final meta = msg.metadata;
|
||
final options = meta?['options'] as List? ?? [];
|
||
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFFFFFF),
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))],
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(msg.content, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A2E))),
|
||
const SizedBox(height: 14),
|
||
Wrap(spacing: 8, runSpacing: 8, children: options.map((opt) {
|
||
final o = opt as Map? ?? {};
|
||
return ElevatedButton(
|
||
onPressed: () {},
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFFF5F3FF),
|
||
foregroundColor: const Color(0xFF635BFF),
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11),
|
||
),
|
||
child: Text(o['label'] as String? ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||
);
|
||
}).toList()),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 公共组件:思考气泡 & 文本气泡
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _buildThinkingBubble(BuildContext context, String? thinkingText) {
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFEFEFF),
|
||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
|
||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 26,
|
||
height: 26,
|
||
padding: const EdgeInsets.all(5),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFEDEBFF),
|
||
borderRadius: BorderRadius.circular(13),
|
||
),
|
||
child: const CircularProgressIndicator(strokeWidth: 2.2, color: Color(0xFF635BFF)),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTextBubble(BuildContext context, ChatMessage msg) {
|
||
final isUser = msg.isUser;
|
||
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(0xFF635BFF) : const Color(0xFFFEFEFF),
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.circular(isUser ? 20 : 4),
|
||
topRight: Radius.circular(isUser ? 4 : 20),
|
||
bottomLeft: const Radius.circular(20),
|
||
bottomRight: const Radius.circular(20),
|
||
),
|
||
border: isUser ? null : Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||
boxShadow: isUser ? [] : [BoxShadow(color: const Color(0xFF635BFF).withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (hasImage)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(10),
|
||
child: imageUrl != null
|
||
? Image.network(imageUrl, fit: BoxFit.cover, width: double.infinity, errorBuilder: (_, __, ___) => _buildLocalFallback(localPath))
|
||
: localPath != null
|
||
? Image.file(File(localPath), fit: BoxFit.cover, width: double.infinity)
|
||
: null,
|
||
),
|
||
),
|
||
if (isUser)
|
||
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
|
||
else
|
||
MarkdownBody(
|
||
data: msg.content,
|
||
selectable: true,
|
||
styleSheet: MarkdownStyleSheet(
|
||
p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5),
|
||
h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
|
||
h2: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
|
||
code: const TextStyle(fontSize: 14, backgroundColor: Color(0xFFF5F3FF)),
|
||
),
|
||
),
|
||
if (!isUser && msg.content.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 10),
|
||
child: Row(children: [
|
||
const CircleAvatar(radius: 10, backgroundColor: Color(0xFFEDEBFF), child: Icon(Icons.chat_bubble_outline, size: 14, color: Color(0xFF635BFF))),
|
||
const SizedBox(width: 6),
|
||
const Text('健康管家', style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
|
||
const SizedBox(width: 4),
|
||
const Text('仅供参考', style: TextStyle(fontSize: 11, color: Color(0xFFCCCCCC))),
|
||
]),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLocalFallback(String? localPath) {
|
||
if (localPath != null) {
|
||
final file = File(localPath);
|
||
return Image.file(file, fit: BoxFit.cover, width: double.infinity);
|
||
}
|
||
return Container(
|
||
height: 100,
|
||
color: const Color(0xFFEEEEEE),
|
||
child: const Center(child: Icon(Icons.broken_image, size: 40, color: Color(0xFFBDBDBD))),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 公共组件:通用按钮
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _cardFilledBtn(String label, IconData icon) {
|
||
return ElevatedButton(
|
||
onPressed: () {},
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFF635BFF),
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||
),
|
||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(icon, size: 16),
|
||
const SizedBox(width: 5),
|
||
Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _cardOutlineBtn(String label, IconData icon) {
|
||
return OutlinedButton(
|
||
onPressed: () {},
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: const Color(0xFF635BFF),
|
||
side: const BorderSide(color: Color(0xFF635BFF), width: 1.2),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||
),
|
||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(icon, size: 15),
|
||
const SizedBox(width: 4),
|
||
Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
|
||
]),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 工具方法
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
String _formatTime(DateTime dt) {
|
||
final now = DateTime.now();
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
final thatDay = DateTime(dt.year, dt.month, dt.day);
|
||
if (thatDay == today) {
|
||
return '今天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
if (thatDay == today.subtract(const Duration(days: 1))) {
|
||
return '昨天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
String _getMetricUnit(String type) {
|
||
switch (type.toLowerCase()) {
|
||
case 'blood_pressure': return 'mmHg';
|
||
case 'heart_rate': return 'bpm';
|
||
case 'glucose': return 'mmol/L';
|
||
case 'spo2': return '%';
|
||
case 'weight': return 'kg';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
String _getMetricIcon(String type) {
|
||
switch (type.toLowerCase()) {
|
||
case 'blood_pressure': return '🩺';
|
||
case 'heart_rate': return '💓';
|
||
case 'glucose': return '💉';
|
||
case 'spo2': return '🫁';
|
||
case 'weight': return '⚖️';
|
||
default: return '📊';
|
||
}
|
||
}
|
||
|
||
String _getMetricName(String type) {
|
||
switch (type.toLowerCase()) {
|
||
case 'blood_pressure': return '血压';
|
||
case 'heart_rate': return '心率';
|
||
case 'glucose': return '血糖';
|
||
case 'spo2': return '血氧';
|
||
case 'weight': return '体重';
|
||
default: return '健康指标';
|
||
}
|
||
}
|
||
|
||
static 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 (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) {
|
||
return switch (agent) {
|
||
ActiveAgent.health => (Icons.favorite_border, '记数据', '录入血压、血糖、心率等日常指标'),
|
||
ActiveAgent.diet => (Icons.restaurant, '拍饮食', '拍照识别食物热量和营养成分'),
|
||
ActiveAgent.medication => (Icons.medication, '药管家', '管理药品、提醒服药、追踪用量'),
|
||
ActiveAgent.consultation => (Icons.local_hospital, '问诊', '在线咨询医生,描述症状获取建议'),
|
||
ActiveAgent.report => (Icons.assignment, '看报告', '上传体检报告,AI 辅助解读'),
|
||
ActiveAgent.exercise => (Icons.directions_run, '运动', '制定运动计划,打卡记录进度'),
|
||
_ => (Icons.smart_toy, 'AI 助手', '您的智能健康管家'),
|
||
};
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// 内部数据类
|
||
// ════════════════════════════════════════════════════════════════
|
||
|
||
typedef _AgentIcon = IconData;
|
||
|
||
class _AgentAction {
|
||
final String label;
|
||
final IconData icon;
|
||
final bool isWide;
|
||
final String? route;
|
||
|
||
const _AgentAction({required this.label, required this.icon, this.isWide = false, this.route});
|
||
}
|
||
|
||
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: 'camera'),
|
||
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, route: 'gallery'),
|
||
],
|
||
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: 'exercisePlan'),
|
||
_AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true, route: 'exercisePlan'),
|
||
],
|
||
};
|
||
|
||
extension _AgentActionsExt on ActiveAgent {
|
||
List<_AgentAction> get actions => _agentActions[this] ?? [const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)];
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// 可展开的 AI 建议小组件
|
||
// ════════════════════════════════════════════════════════════════
|
||
|
||
class _ExpandableAdvice extends StatefulWidget {
|
||
final String advice;
|
||
const _ExpandableAdvice({required this.advice});
|
||
|
||
@override
|
||
State<_ExpandableAdvice> createState() => _ExpandableAdviceState();
|
||
}
|
||
|
||
class _ExpandableAdviceState extends State<_ExpandableAdvice> {
|
||
bool _expanded = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: () => setState(() => _expanded = !_expanded),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF7F5FF),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.lightbulb_outline, size: 16, color: Color(0xFF635BFF)),
|
||
const SizedBox(width: 6),
|
||
const Text('AI 建议', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))),
|
||
const Spacer(),
|
||
Icon(_expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 18, color: const Color(0xFFAAAAAA)),
|
||
],
|
||
),
|
||
if (_expanded) ...[
|
||
const SizedBox(height: 10),
|
||
Text(widget.advice, style: const TextStyle(fontSize: 13, color: Color(0xFF555555), height: 1.6)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|