前端: - 新增 DietCapturePage 独立拍照识别页 - 5种消息卡片类型完整实现(数据确认/用药/饮食/报告/快捷选项) - 任务卡片区:异常警告+数据摘要+自动折叠 - 侧滑抽屉:历史对话列表+对话管理 - 运动计划:进度卡片+创建计划+每日打卡 - 报告页:拍照/相册/PDF上传+分析 - 面板按钮补全血氧/体重录入 - UI 升级:紫色主题+动画+气泡样式 - 全部迁移 Riverpod 3.x API 后端: - 新增 _UpdateMessageTypeAndMetadata,Tool Calling 自动映射消息类型 - SSE answer 事件携带 type 字段 - 提示词优化(移除"阿福",语气规则归位) - 运动计划支持 AI 创建和打卡 测试: - 新增 full_e2e_test.py 全流程测试(认证/数据CRUD/6个Agent对话/VLM/报告)
536 lines
18 KiB
Dart
536 lines
18 KiB
Dart
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 '../../core/api_client.dart';
|
||
import '../../core/navigation_provider.dart';
|
||
import '../../providers/auth_provider.dart';
|
||
import '../../providers/chat_provider.dart';
|
||
import '../../providers/data_providers.dart';
|
||
import '../../widgets/agent_bar.dart';
|
||
import '../../widgets/health_drawer.dart';
|
||
import 'widgets/chat_messages_view.dart';
|
||
|
||
/// 首页——主界面
|
||
class HomePage extends ConsumerStatefulWidget {
|
||
const HomePage({super.key});
|
||
@override
|
||
ConsumerState<HomePage> createState() => _HomePageState();
|
||
}
|
||
|
||
class _HomePageState extends ConsumerState<HomePage> {
|
||
final _textCtrl = TextEditingController();
|
||
final _scrollCtrl = ScrollController();
|
||
bool _taskCardsExpanded = true;
|
||
bool _showExpandButton = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_scrollCtrl.addListener(_onScroll);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_textCtrl.dispose();
|
||
_scrollCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onScroll() {
|
||
if (_scrollCtrl.offset > 50 && !_showExpandButton) {
|
||
setState(() => _showExpandButton = true);
|
||
} else if (_scrollCtrl.offset <= 50 && _showExpandButton) {
|
||
setState(() => _showExpandButton = false);
|
||
}
|
||
}
|
||
|
||
void _sendMessage() {
|
||
final text = _textCtrl.text.trim();
|
||
if (text.isEmpty) return;
|
||
_textCtrl.clear();
|
||
ref.read(chatProvider.notifier).sendMessage(text);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final chatState = ref.watch(chatProvider);
|
||
final selectedAgent = ref.watch(selectedAgentProvider);
|
||
|
||
return Scaffold(
|
||
drawer: const HealthDrawer(),
|
||
body: SafeArea(
|
||
child: Stack(children: [
|
||
Column(children: [
|
||
_buildHeader(context),
|
||
if (_taskCardsExpanded) _buildTaskCards(),
|
||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||
_buildAgentPanel(context, selectedAgent),
|
||
const AgentBar(),
|
||
_buildInputBar(),
|
||
]),
|
||
_buildExpandButton(),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildExpandButton() {
|
||
if (!_showExpandButton || _taskCardsExpanded) return const SizedBox.shrink();
|
||
|
||
return Positioned(
|
||
top: 60,
|
||
right: 16,
|
||
child: AnimatedOpacity(
|
||
opacity: _showExpandButton ? 1.0 : 0.0,
|
||
duration: const Duration(milliseconds: 300),
|
||
child: FloatingActionButton(
|
||
onPressed: () => setState(() => _taskCardsExpanded = true),
|
||
mini: true,
|
||
backgroundColor: const Color(0xFF635BFF),
|
||
child: const Icon(Icons.keyboard_arrow_down, size: 20),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildHeader(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Row(children: [
|
||
Builder(builder: (ctx) => IconButton(
|
||
icon: const Icon(Icons.menu, size: 24),
|
||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||
)),
|
||
const Spacer(),
|
||
Text('健康管家', style: Theme.of(context).textTheme.titleLarge),
|
||
const Spacer(),
|
||
const SizedBox(width: 48),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _buildTaskCards() {
|
||
final latestHealth = ref.watch(latestHealthProvider);
|
||
|
||
return latestHealth.when(
|
||
data: (data) {
|
||
final tasks = _getTaskCards(data);
|
||
if (tasks.isEmpty) return const SizedBox.shrink();
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFEFEFF),
|
||
borderRadius: BorderRadius.circular(24),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
|
||
),
|
||
child: Column(children: [
|
||
Row(children: [
|
||
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
|
||
const SizedBox(width: 8),
|
||
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||
const Spacer(),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _taskCardsExpanded = false),
|
||
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
|
||
),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
Column(children: tasks),
|
||
]),
|
||
);
|
||
},
|
||
loading: () => const SizedBox.shrink(),
|
||
error: (_, __) {
|
||
final tasks = _getTaskCards(const {});
|
||
if (tasks.isEmpty) return const SizedBox.shrink();
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFEFEFF),
|
||
borderRadius: BorderRadius.circular(24),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
|
||
),
|
||
child: Column(children: [
|
||
Row(children: [
|
||
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
|
||
const SizedBox(width: 8),
|
||
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||
const Spacer(),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _taskCardsExpanded = false),
|
||
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
|
||
),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
Column(children: tasks),
|
||
]),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
String _getGreeting() {
|
||
final hour = DateTime.now().hour;
|
||
if (hour < 6) return '夜深了';
|
||
if (hour < 9) return '早上好';
|
||
if (hour < 12) return '上午好';
|
||
if (hour < 14) return '中午好';
|
||
if (hour < 18) return '下午好';
|
||
if (hour < 22) return '晚上好';
|
||
return '夜深了';
|
||
}
|
||
|
||
List<Widget> _getTaskCards(Map<String, dynamic> healthData) {
|
||
final cards = <Widget>[];
|
||
|
||
cards.add(_buildMedicationCard());
|
||
cards.add(_buildExerciseCard());
|
||
cards.add(_buildMeasurementCard());
|
||
|
||
final abnormalCards = _buildAbnormalCards(healthData);
|
||
cards.addAll(abnormalCards);
|
||
|
||
final summaryCard = _buildSummaryCard(healthData);
|
||
if (summaryCard != null) cards.add(summaryCard);
|
||
|
||
return cards;
|
||
}
|
||
|
||
Widget _buildMedicationCard() {
|
||
return _buildTaskCard(
|
||
'💊',
|
||
'计划 8:00 吃 阿司匹林 100mg',
|
||
Icons.check_circle_outline,
|
||
() => _handleMedicationCheck(),
|
||
type: 'medication',
|
||
);
|
||
}
|
||
|
||
Widget _buildExerciseCard() {
|
||
return _buildTaskCard(
|
||
'🏃',
|
||
'今日待运动:散步 30 分钟',
|
||
Icons.check_circle_outline,
|
||
() => _handleExerciseCheck(),
|
||
type: 'exercise',
|
||
);
|
||
}
|
||
|
||
Widget _buildMeasurementCard() {
|
||
return _buildTaskCard(
|
||
'🩺',
|
||
'今日待测量:血压',
|
||
Icons.arrow_forward_ios,
|
||
() => _textCtrl.text = '血压 ',
|
||
type: 'measurement',
|
||
);
|
||
}
|
||
|
||
List<Widget> _buildAbnormalCards(Map<String, dynamic> healthData) {
|
||
final cards = <Widget>[];
|
||
|
||
final bp = healthData['BloodPressure'];
|
||
if (bp != null && bp is Map) {
|
||
final systolic = bp['systolic'];
|
||
final diastolic = bp['diastolic'];
|
||
if (systolic != null && systolic >= 140) {
|
||
cards.add(_buildTaskCard(
|
||
'⚠️',
|
||
'昨日血压 ${systolic}/${diastolic ?? '--'},偏高',
|
||
Icons.arrow_forward_ios,
|
||
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||
type: 'warning',
|
||
highlight: true,
|
||
));
|
||
}
|
||
}
|
||
|
||
final hr = healthData['HeartRate'];
|
||
if (hr != null && hr is Map) {
|
||
final value = hr['value'];
|
||
if (value != null && (value > 100 || value < 60)) {
|
||
cards.add(_buildTaskCard(
|
||
'⚠️',
|
||
'昨日心率 $value,${value > 100 ? '偏高' : '偏低'}',
|
||
Icons.arrow_forward_ios,
|
||
() => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}),
|
||
type: 'warning',
|
||
highlight: true,
|
||
));
|
||
}
|
||
}
|
||
|
||
return cards;
|
||
}
|
||
|
||
Widget? _buildSummaryCard(Map<String, dynamic> healthData) {
|
||
final values = <String>[];
|
||
|
||
final bp = healthData['BloodPressure'];
|
||
if (bp != null && bp is Map) {
|
||
final sys = bp['systolic'];
|
||
final dia = bp['diastolic'];
|
||
if (sys != null && dia != null) values.add('血压 $sys/$dia');
|
||
}
|
||
|
||
final hr = healthData['HeartRate'];
|
||
if (hr != null && hr is Map && hr['value'] != null) {
|
||
values.add('心率 ${hr['value']}');
|
||
}
|
||
|
||
final glucose = healthData['Glucose'];
|
||
if (glucose != null && glucose is Map && glucose['value'] != null) {
|
||
values.add('血糖 ${glucose['value']}');
|
||
}
|
||
|
||
if (values.isEmpty) return null;
|
||
|
||
return _buildTaskCard(
|
||
'📊',
|
||
'今日已记录:${values.join('、')}',
|
||
Icons.arrow_forward_ios,
|
||
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||
type: 'summary',
|
||
);
|
||
}
|
||
|
||
Widget _buildTaskCard(String icon, String text, IconData actionIcon, VoidCallback onTap, {String type = '', bool highlight = false}) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
decoration: highlight ? BoxDecoration(
|
||
color: const Color(0xFFFDF2F2),
|
||
borderRadius: BorderRadius.circular(12),
|
||
) : null,
|
||
child: Row(children: [
|
||
Text(icon, style: const TextStyle(fontSize: 20)),
|
||
const SizedBox(width: 10),
|
||
Expanded(child: Text(text, style: TextStyle(
|
||
fontSize: 14,
|
||
color: highlight ? const Color(0xFFDC2626) : const Color(0xFF333333),
|
||
))),
|
||
GestureDetector(
|
||
onTap: onTap,
|
||
child: Icon(actionIcon, size: 20, color: highlight ? const Color(0xFFDC2626) : const Color(0xFF635BFF)),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _handleMedicationCheck() async {
|
||
await ref.read(medicationServiceProvider).confirm('');
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||
content: Text('已记录服药 ✅'),
|
||
backgroundColor: Color(0xFF635BFF),
|
||
duration: Duration(seconds: 2),
|
||
));
|
||
}
|
||
|
||
void _handleExerciseCheck() async {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||
content: Text('已完成运动 ✅'),
|
||
backgroundColor: Color(0xFF635BFF),
|
||
duration: Duration(seconds: 2),
|
||
));
|
||
}
|
||
|
||
Widget _buildAgentPanel(BuildContext context, ActiveAgent? agent) {
|
||
if (agent == null) return const SizedBox.shrink();
|
||
|
||
return AnimatedContainer(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFEFEFF),
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 12, offset: const Offset(0, -4))],
|
||
),
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
_buildAgentPanelHeader(agent),
|
||
const SizedBox(height: 12),
|
||
..._getAgentButtons(agent),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _buildAgentPanelHeader(ActiveAgent agent) {
|
||
final titles = {
|
||
ActiveAgent.consultation: '🩺 AI 问诊',
|
||
ActiveAgent.health: '📊 记数据',
|
||
ActiveAgent.diet: '📸 拍饮食',
|
||
ActiveAgent.medication: '💊 药管家',
|
||
ActiveAgent.report: '📋 看报告',
|
||
ActiveAgent.exercise: '🏃 运动计划',
|
||
};
|
||
final tips = {
|
||
ActiveAgent.consultation: '或直接对我说你的症状',
|
||
ActiveAgent.health: '或直接对我说:"血压 135/85"',
|
||
ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"',
|
||
ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"',
|
||
ActiveAgent.report: '或直接上传报告图片',
|
||
ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"',
|
||
};
|
||
|
||
return Column(children: [
|
||
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||
const SizedBox(height: 4),
|
||
Text(tips[agent] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||
]);
|
||
}
|
||
|
||
List<Widget> _getAgentButtons(ActiveAgent agent) {
|
||
final buttons = <Widget>[];
|
||
if (agent == ActiveAgent.health) {
|
||
buttons.add(_panelBtn('手动录入血压', Icons.favorite));
|
||
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype));
|
||
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart));
|
||
buttons.add(_panelBtn('手动录入血氧', Icons.air));
|
||
buttons.add(_panelBtn('手动录入体重', Icons.monitor_weight));
|
||
} else if (agent == ActiveAgent.diet) {
|
||
buttons.add(_panelBtn('拍照', Icons.camera_alt));
|
||
buttons.add(_panelBtn('上传照片', Icons.photo_library));
|
||
} else if (agent == ActiveAgent.medication) {
|
||
buttons.add(_panelBtn('用药管理', Icons.medication));
|
||
buttons.add(_panelBtn('用药提醒', Icons.alarm));
|
||
} else if (agent == ActiveAgent.consultation) {
|
||
buttons.add(_panelBtn('找医生', Icons.person_search));
|
||
} else if (agent == ActiveAgent.exercise) {
|
||
buttons.add(_panelBtn('查看本周计划', Icons.calendar_view_week));
|
||
buttons.add(_panelBtn('创建新计划', Icons.add_circle_outline));
|
||
}
|
||
return buttons;
|
||
}
|
||
|
||
Widget _panelBtn(String label, IconData icon) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton.icon(
|
||
onPressed: () => _onAgentAction(label),
|
||
icon: Icon(icon, size: 18),
|
||
label: Text(label, style: const TextStyle(fontSize: 14)),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFFF5F3FF),
|
||
foregroundColor: const Color(0xFF635BFF),
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _onAgentAction(String label) {
|
||
switch (label) {
|
||
case '拍照':
|
||
pushRoute(ref, 'dietCapture');
|
||
break;
|
||
case '上传照片':
|
||
pushRoute(ref, 'dietCapture');
|
||
break;
|
||
case '手动录入血压':
|
||
_textCtrl.text = '血压 ';
|
||
break;
|
||
case '手动录入血糖':
|
||
_textCtrl.text = '血糖 ';
|
||
break;
|
||
case '手动录入心率':
|
||
_textCtrl.text = '心率 ';
|
||
break;
|
||
case '手动录入血氧':
|
||
_textCtrl.text = '血氧 ';
|
||
break;
|
||
case '手动录入体重':
|
||
_textCtrl.text = '体重 ';
|
||
break;
|
||
case '用药管理':
|
||
pushRoute(ref, 'medications');
|
||
break;
|
||
case '找医生':
|
||
pushRoute(ref, 'doctors');
|
||
break;
|
||
case '查看本周计划':
|
||
pushRoute(ref, 'exercisePlan');
|
||
break;
|
||
case '创建新计划':
|
||
pushRoute(ref, 'exercisePlan');
|
||
break;
|
||
}
|
||
}
|
||
|
||
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 = '[图片已上传] $baseUrl/api/files/${picked.path.split('/').last}';
|
||
setState(() {});
|
||
}
|
||
}
|
||
|
||
void _showAttachmentPicker(BuildContext context) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
builder: (ctx) => SafeArea(
|
||
child: Wrap(
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.camera_alt),
|
||
title: const Text('拍照'),
|
||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_library),
|
||
title: const Text('从相册选'),
|
||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.attach_file),
|
||
title: const Text('传文件'),
|
||
onTap: () async {
|
||
Navigator.pop(ctx);
|
||
final result = await FilePicker.platform.pickFiles();
|
||
if (result != null && result.files.isNotEmpty) {
|
||
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
||
setState(() {});
|
||
}
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInputBar() {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||
),
|
||
child: Row(children: [
|
||
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _textCtrl,
|
||
decoration: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none),
|
||
onSubmitted: (_) => _sendMessage(),
|
||
),
|
||
),
|
||
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
|
||
]),
|
||
);
|
||
}
|
||
}
|