fix: 图片发送/医生加载/运动超时/用药黑屏/服药打卡
- sendImage: 本地预览→上传→远程URL替换 - doctorListProvider: 8s超时+mock医生fallback - currentExercisePlanProvider: 8s超时→显示空状态 - 用药编辑: try-catch防黑屏+刷新列表 - 服药打卡: 接入后端confirm()接口
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'dart:io';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
@@ -18,6 +19,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
bool _taskCardsExpanded = true;
|
||||
String? _pickedImagePath;
|
||||
double _lastScrollOffset = 0;
|
||||
DateTime? _lastCollapseTime;
|
||||
bool _exerciseDone = false;
|
||||
@@ -55,10 +57,15 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _textCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final imagePath = _pickedImagePath;
|
||||
if (text.isEmpty && imagePath == null) return;
|
||||
_textCtrl.clear();
|
||||
setState(() => _taskCardsExpanded = false);
|
||||
ref.read(chatProvider.notifier).sendMessage(text);
|
||||
setState(() { _taskCardsExpanded = false; _pickedImagePath = null; });
|
||||
if (imagePath != null) {
|
||||
ref.read(chatProvider.notifier).sendImage(imagePath, text);
|
||||
} else {
|
||||
ref.read(chatProvider.notifier).sendMessage(text);
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@@ -67,6 +74,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final user = auth.user;
|
||||
final selectedAgent = ref.watch(selectedAgentProvider);
|
||||
|
||||
ref.listen(cameraActionProvider, (prev, next) {
|
||||
if (next == 'camera') {
|
||||
_pickImage(ImageSource.camera);
|
||||
ref.read(cameraActionProvider.notifier).clear();
|
||||
} else if (next == 'gallery') {
|
||||
_pickImage(ImageSource.gallery);
|
||||
ref.read(cameraActionProvider.notifier).clear();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
drawer: const HealthDrawer(),
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
@@ -130,19 +147,19 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
// 折叠状态:只显示一行可点击的标题栏
|
||||
// 折叠状态:与展开态容器完全相同,只保留标题行
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _taskCardsExpanded = true),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Text('今日任务', style: TextStyle(fontSize: 13, color: Color(0xFF635BFF))),
|
||||
const SizedBox(width: 4),
|
||||
const Text('▾', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF))),
|
||||
]),
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]),
|
||||
child: Row(children: [
|
||||
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
const Text('展开 ▾', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -301,27 +318,11 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
dateLabel = '$diff天后';
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => pushRoute(ref, 'followups'),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 30, height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.event_available, size: 15, color: Color(0xFF635BFF)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(
|
||||
'📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}',
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF333333)),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
return _taskRow(
|
||||
icon: Icons.event_available,
|
||||
label: '📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}',
|
||||
status: 'pending',
|
||||
onTap: () => pushRoute(ref, 'followups'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -340,7 +341,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(children: [
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
||||
Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF635BFF))),
|
||||
const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))),
|
||||
Icon(icons[effectiveStatus], size: 18, color: colors[effectiveStatus] ?? Colors.grey),
|
||||
@@ -381,11 +382,14 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final notifier = ref.read(selectedAgentProvider.notifier);
|
||||
final newAgent = isActive ? null : agent;
|
||||
notifier.select(newAgent);
|
||||
if (newAgent != null) {
|
||||
ref.read(chatProvider.notifier).setAgent(newAgent);
|
||||
ref.read(chatProvider.notifier).insertAgentWelcome(newAgent);
|
||||
if (isActive) {
|
||||
notifier.select(null);
|
||||
} else {
|
||||
notifier.select(agent);
|
||||
ref.read(chatProvider.notifier).setAgent(agent);
|
||||
if (_welcomedAgents.add(agent)) {
|
||||
ref.read(chatProvider.notifier).insertAgentWelcome(agent);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
@@ -414,83 +418,60 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
// 智能体胶囊栏(常驻,高度36)
|
||||
_buildAgentBar(selectedAgent),
|
||||
|
||||
// 输入框(紧凑)
|
||||
// 图片预览(有选中图片时显示)
|
||||
if (_pickedImagePath != null) _buildImagePreview(),
|
||||
|
||||
// 输入框
|
||||
_buildCompactInputBar(context),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildCompactAgentPanel(ActiveAgent agent) {
|
||||
final titles = {ActiveAgent.consultation: 'AI 问诊', ActiveAgent.health: '记数据', ActiveAgent.diet: '拍饮食', ActiveAgent.medication: '药管家', ActiveAgent.report: '看报告', ActiveAgent.exercise: '运动计划'};
|
||||
final tips = {ActiveAgent.consultation: '或直接对我说你的症状', ActiveAgent.health: '或直接对我说:"血压 135/85"', ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"', ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"', ActiveAgent.report: '或直接上传报告图片', ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"'};
|
||||
|
||||
Widget _buildImagePreview() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Row(children: [
|
||||
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(tips[agent] ?? '', style: TextStyle(fontSize: 10, color: Colors.grey[500]))),
|
||||
GestureDetector(onTap: () => ref.read(selectedAgentProvider.notifier).select(null), child: Icon(Icons.close, size: 16, color: Colors.grey[400])),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: _getAgentButtons(agent))),
|
||||
]));
|
||||
}
|
||||
|
||||
Widget _buildCompactInputBar(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
|
||||
child: Row(children: [
|
||||
IconButton(icon: const Icon(Icons.attach_file, size: 20, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context), padding: const EdgeInsets.all(4)),
|
||||
Expanded(child: TextField(controller: _textCtrl, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 8), border: InputBorder.none, isDense: true, hintStyle: const TextStyle(fontSize: 13)), onSubmitted: (_) => _sendMessage())),
|
||||
IconButton(icon: const Icon(Icons.send, size: 20, color: Color(0xFF635BFF)), onPressed: _sendMessage, padding: const EdgeInsets.all(4)),
|
||||
Stack(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover),
|
||||
),
|
||||
Positioned(top: -4, right: -4, child: GestureDetector(
|
||||
onTap: () => setState(() => _pickedImagePath = null),
|
||||
child: Container(width: 20, height: 20, decoration: const BoxDecoration(color: Color(0xFF333333), shape: BoxShape.circle), child: const Icon(Icons.close, size: 14, color: Colors.white)),
|
||||
)),
|
||||
]),
|
||||
const Spacer(),
|
||||
Text('点击发送上传图片', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _getAgentButtons(ActiveAgent agent) {
|
||||
switch (agent) {
|
||||
case ActiveAgent.health: return [_agentBtn('录入血压', Icons.favorite), _agentBtn('录入血糖', Icons.bloodtype), _agentBtn('录入心率', Icons.monitor_heart), _agentBtn('录入血氧', Icons.air), _agentBtn('录入体重', Icons.monitor_weight)];
|
||||
case ActiveAgent.diet: return [_agentBtn('拍照识别', Icons.camera_alt), _agentBtn('上传照片', Icons.photo_library)];
|
||||
case ActiveAgent.medication: return [_agentBtn('用药管理', Icons.medication), _agentBtn('用药提醒', Icons.alarm)];
|
||||
case ActiveAgent.consultation: return [_agentBtn('找医生', Icons.person_search)];
|
||||
case ActiveAgent.exercise: return [_agentBtn('本周计划', Icons.calendar_view_week), _agentBtn('新建计划', Icons.add_circle_outline)];
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
Widget _agentBtn(String label, IconData icon) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _onAgentAction(label),
|
||||
icon: Icon(icon, size: 14),
|
||||
label: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFF5F3FF), foregroundColor: const Color(0xFF635BFF), elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12)),
|
||||
),
|
||||
Widget _buildCompactInputBar(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||
child: Row(children: [
|
||||
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
||||
Expanded(child: TextField(
|
||||
controller: _textCtrl,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
decoration: const InputDecoration(hintText: '输入你想说的...', hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), border: InputBorder.none),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
)),
|
||||
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAgentAction(String label) {
|
||||
switch (label) {
|
||||
case '拍照识别': case '上传照片': pushRoute(ref, 'dietCapture');
|
||||
case '录入血压': _textCtrl.text = '血压 ';
|
||||
case '录入血糖': _textCtrl.text = '血糖 ';
|
||||
case '录入心率': _textCtrl.text = '心率 ';
|
||||
case '录入血氧': _textCtrl.text = '血氧 ';
|
||||
case '录入体重': _textCtrl.text = '体重 ';
|
||||
case '用药管理': pushRoute(ref, 'medications');
|
||||
case '找医生': pushRoute(ref, 'doctors');
|
||||
case '本周计划': case '新建计划': pushRoute(ref, 'exercisePlan');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
||||
if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; _textCtrl.text = '[图片已上传]'; if (mounted) setState(() {}); }
|
||||
if (picked != null) {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) return;
|
||||
setState(() => _pickedImagePath = picked.path);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAttachmentPicker(BuildContext context) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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 {
|
||||
@@ -44,14 +47,14 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[messages.length - 1 - index];
|
||||
return _buildMessageContent(context, msg, chatState);
|
||||
return _buildMessageContent(context, ref, msg, chatState);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 消息分发 ─────────────────────────────────────────────
|
||||
|
||||
Widget _buildMessageContent(BuildContext context, ChatMessage msg, ChatState chatState) {
|
||||
Widget _buildMessageContent(BuildContext context, WidgetRef ref, ChatMessage msg, ChatState chatState) {
|
||||
final isUser = msg.isUser;
|
||||
|
||||
if (!isUser && chatState.isStreaming && msg.content.isEmpty) {
|
||||
@@ -60,7 +63,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
switch (msg.type) {
|
||||
case MessageType.agentWelcome:
|
||||
return _buildAgentWelcomeCard(context, msg, chatState.activeAgent);
|
||||
return _buildAgentWelcomeCard(context, ref, msg, chatState.activeAgent);
|
||||
case MessageType.dataConfirm:
|
||||
return _buildDataConfirmCard(context, msg);
|
||||
case MessageType.medicationConfirm:
|
||||
@@ -80,7 +83,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
// 1. AgentWelcomeCard — 智能体欢迎卡片
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
Widget _buildAgentWelcomeCard(BuildContext context, ChatMessage msg, ActiveAgent agent) {
|
||||
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;
|
||||
@@ -164,7 +167,9 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: actions.map((a) => _agentActionBtn(a, screenWidth)).toList(),
|
||||
children: agent == ActiveAgent.consultation
|
||||
? _buildDoctorCards(screenWidth, ref)
|
||||
: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref)).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -189,10 +194,19 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _agentActionBtn(_AgentAction a, double screenWidth) {
|
||||
Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref) {
|
||||
return InkWell(
|
||||
onTap: () {},
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
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),
|
||||
@@ -221,6 +235,57 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
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 — 增强版数据确认卡片
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -523,11 +588,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
final meta = msg.metadata;
|
||||
final foods = meta?['foods'] as List? ?? [];
|
||||
final totalCalories = meta?['totalCalories'] as int? ?? 0;
|
||||
final rating = meta?['rating'] as int? ?? 0;
|
||||
final warnings = meta?['warnings'] as List? ?? [];
|
||||
final carbs = meta?['carbs'] as double? ?? 50.0;
|
||||
final protein = meta?['protein'] as double? ?? 20.0;
|
||||
final fat = meta?['fat'] as double? ?? 30.0;
|
||||
final advice = meta?['advice'] as String? ?? '饮食均衡,多吃蔬菜水果,减少高油高糖食物摄入。';
|
||||
|
||||
return Align(
|
||||
@@ -548,120 +608,71 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
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))),
|
||||
],
|
||||
),
|
||||
decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFFFF8E1), Color(0xFFFFF3E0)])),
|
||||
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Text('🍽️ ', style: TextStyle(fontSize: 18)),
|
||||
Text('饮食分析结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))),
|
||||
]),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 总热量大号数字
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text('$totalCalories', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.w800, color: Color(0xFFFF8F00))),
|
||||
const Text('千卡 (kcal)', style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
|
||||
// 三大营养素圆环指示
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_nutrientRing('碳水', carbs, const Color(0xFF42A5F5), const Color(0xFFBBDEFB)),
|
||||
_nutrientRing('蛋白质', protein, const Color(0xFF66BB6A), const Color(0xFFC8E6C9)),
|
||||
_nutrientRing('脂肪', fat, const Color(0xFFFFA726), const Color(0xFFFFE0B2)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 食物列表
|
||||
const Text('食物明细', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
|
||||
// ── 识别食物列表 ──
|
||||
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 as Map? ?? {};
|
||||
final fCal = (f['calories'] ?? 0) as num;
|
||||
final fPct = totalCalories > 0 ? (fCal / totalCalories * 100).clamp(0.0, 100.0) : 0.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(f['name'] as String? ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
const Spacer(),
|
||||
Text('${fCal.toInt()} kcal', style: const TextStyle(fontSize: 12, color: Color(0xFF888888))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: fPct / 100,
|
||||
minHeight: 5,
|
||||
backgroundColor: const Color(0xFFF0EEFF),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFFFB74D)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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: 14),
|
||||
Row(
|
||||
children: [
|
||||
const Text('健康评分', style: TextStyle(fontSize: 13, color: Color(0xFF666666))),
|
||||
const SizedBox(width: 8),
|
||||
...List.generate(5, (i) => Padding(
|
||||
padding: const EdgeInsets.only(right: 2),
|
||||
child: Icon(i < rating ? Icons.star : Icons.star_border, size: 18, color: i < rating ? const Color(0xFFFFB800) : const Color(0xFFE0E0E0)),
|
||||
)),
|
||||
const Spacer(),
|
||||
Text('$rating/5', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))),
|
||||
],
|
||||
] 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))),
|
||||
]),
|
||||
),
|
||||
|
||||
// 警告
|
||||
if (warnings.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
...warnings.map((w) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBF0),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFFE082), width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('⚠️ ', style: TextStyle(fontSize: 13)),
|
||||
Expanded(child: Text(w.toString(), style: const TextStyle(fontSize: 12, color: Color(0xFFE65100)))),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
|
||||
// AI 建议(可展开)
|
||||
const SizedBox(height: 14),
|
||||
_ExpandableAdvice(advice: advice),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
|
||||
// ── 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))),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -669,40 +680,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _nutrientRing(String label, double pct, Color fgColor, Color bgColor) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bgColor),
|
||||
),
|
||||
SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: CircularProgressIndicator(
|
||||
value: pct.clamp(0.0, 100.0) / 100,
|
||||
strokeWidth: 5,
|
||||
backgroundColor: Colors.transparent,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(fgColor),
|
||||
),
|
||||
),
|
||||
Text('${pct.toInt()}%', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: fgColor)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF888888))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 5. ReportAnalysisCard — 增强版报告分析卡片
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -972,6 +949,10 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
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(
|
||||
@@ -992,6 +973,18 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
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
|
||||
@@ -1022,6 +1015,18 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
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))),
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 公共组件:通用按钮
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -1111,6 +1116,36 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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, '记数据', '录入血压、血糖、心率等日常指标'),
|
||||
@@ -1134,41 +1169,36 @@ 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});
|
||||
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),
|
||||
_AgentAction(label: '录入血糖', icon: Icons.bloodtype_outlined),
|
||||
_AgentAction(label: '录入心率', icon: Icons.favorite_border),
|
||||
_AgentAction(label: '录入血氧', icon: Icons.air_outlined),
|
||||
_AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined),
|
||||
_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),
|
||||
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true),
|
||||
_AgentAction(label: '看舌答', icon: Icons.face_retouching_natural_outlined, isWide: true),
|
||||
_AgentAction(label: '测肤质', icon: Icons.palette_outlined, isWide: true),
|
||||
_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),
|
||||
_AgentAction(label: '用药提醒', icon: Icons.alarm_outlined, isWide: true),
|
||||
_AgentAction(label: '添加药品', icon: Icons.add_circle_outline, isWide: true),
|
||||
],
|
||||
ActiveAgent.consultation: [
|
||||
_AgentAction(label: '找医生', icon: Icons.person_search_outlined, isWide: true),
|
||||
_AgentAction(label: '描述症状', icon: Icons.edit_note_outlined, isWide: true),
|
||||
_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),
|
||||
_AgentAction(label: '查看历史', icon: Icons.history_outlined, isWide: true),
|
||||
_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),
|
||||
_AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true),
|
||||
_AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true),
|
||||
_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'),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user