前端: - 新增 DietCapturePage 独立拍照识别页 - 5种消息卡片类型完整实现(数据确认/用药/饮食/报告/快捷选项) - 任务卡片区:异常警告+数据摘要+自动折叠 - 侧滑抽屉:历史对话列表+对话管理 - 运动计划:进度卡片+创建计划+每日打卡 - 报告页:拍照/相册/PDF上传+分析 - 面板按钮补全血氧/体重录入 - UI 升级:紫色主题+动画+气泡样式 - 全部迁移 Riverpod 3.x API 后端: - 新增 _UpdateMessageTypeAndMetadata,Tool Calling 自动映射消息类型 - SSE answer 事件携带 type 字段 - 提示词优化(移除"阿福",语气规则归位) - 运动计划支持 AI 创建和打卡 测试: - 新增 full_e2e_test.py 全流程测试(认证/数据CRUD/6个Agent对话/VLM/报告)
232 lines
10 KiB
Dart
232 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../core/navigation_provider.dart';
|
|
import '../providers/auth_provider.dart';
|
|
import '../providers/data_providers.dart';
|
|
import '../providers/chat_provider.dart';
|
|
|
|
/// 侧滑抽屉——健康概览 + 历史对话 + 菜单
|
|
class HealthDrawer extends ConsumerWidget {
|
|
const HealthDrawer({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final auth = ref.watch(authProvider);
|
|
final user = auth.user;
|
|
final latestHealth = ref.watch(latestHealthProvider);
|
|
final conversations = ref.watch(conversationListProvider);
|
|
|
|
return Drawer(
|
|
child: SafeArea(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 用户信息
|
|
Container(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () => pushRoute(ref, 'profile'),
|
|
child: CircleAvatar(
|
|
radius: 28,
|
|
backgroundColor: const Color(0xFFEDEBFF),
|
|
child: Icon(Icons.person, size: 32, color: Theme.of(context).colorScheme.primary),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(user?.name ?? '未设置昵称', style: Theme.of(context).textTheme.titleMedium),
|
|
if (user != null) const SizedBox(height: 4),
|
|
Text(user?.phone ?? '', style: Theme.of(context).textTheme.labelMedium),
|
|
],
|
|
),
|
|
),
|
|
_DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')),
|
|
const Divider(),
|
|
|
|
// 健康概览——接真实数据
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
|
child: Text('健康概览', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)),
|
|
),
|
|
latestHealth.when(
|
|
data: (data) => Column(children: [
|
|
_HealthMetric(icon: Icons.favorite, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
|
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], '次/分'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
|
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], 'mmol/L'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
|
|
_HealthMetric(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
|
|
]),
|
|
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
|
|
error: (_, _) => Column(children: [
|
|
_HealthMetric(icon: Icons.favorite, label: '血压', value: '--'),
|
|
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: '--'),
|
|
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: '--'),
|
|
_HealthMetric(icon: Icons.air, label: '血氧', value: '--'),
|
|
]),
|
|
),
|
|
|
|
const Divider(),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
|
child: Row(children: [
|
|
Text('历史对话', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)),
|
|
const Spacer(),
|
|
TextButton(onPressed: () => ref.invalidate(conversationListProvider), child: const Text('刷新', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF)))),
|
|
]),
|
|
),
|
|
Expanded(
|
|
child: conversations.when(
|
|
data: (items) {
|
|
if (items.isEmpty) {
|
|
return const Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 14)));
|
|
}
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemCount: items.length,
|
|
itemBuilder: (ctx, i) => _ConversationItem(item: items[i], ref: ref),
|
|
);
|
|
},
|
|
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
|
error: (_, __) => const Center(child: Text('加载失败', style: TextStyle(color: Color(0xFF999999), fontSize: 14))),
|
|
),
|
|
),
|
|
|
|
const Divider(),
|
|
_DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async {
|
|
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
|
|
title: const Text('退出登录'), content: const Text('确定退出?'),
|
|
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
|
|
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _bpText(dynamic bp) {
|
|
if (bp == null) return '--';
|
|
if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
|
|
return '--';
|
|
}
|
|
|
|
String _metricText(dynamic metric, String unit) {
|
|
if (metric == null) return '--';
|
|
if (metric is Map) {
|
|
final v = metric['value'];
|
|
return v != null ? '$v $unit' : '--';
|
|
}
|
|
return '--';
|
|
}
|
|
}
|
|
|
|
class _DrawerItem extends StatelessWidget {
|
|
final IconData icon; final String label; final VoidCallback onTap;
|
|
const _DrawerItem({required this.icon, required this.label, required this.onTap});
|
|
@override Widget build(BuildContext context) => ListTile(leading: Icon(icon, size: 20, color: const Color(0xFF666666)), title: Text(label, style: const TextStyle(fontSize: 16)), onTap: onTap, dense: true);
|
|
}
|
|
|
|
class _HealthMetric extends StatelessWidget {
|
|
final IconData icon; final String label; final String value; final VoidCallback? onTap;
|
|
const _HealthMetric({required this.icon, required this.label, required this.value, this.onTap});
|
|
@override Widget build(BuildContext context) => ListTile(
|
|
leading: Icon(icon, size: 18, color: const Color(0xFF635BFF)),
|
|
title: Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
|
|
trailing: Text(value, style: TextStyle(fontSize: 16, color: value == '--' ? const Color(0xFF999999) : const Color(0xFF1A1A1A))),
|
|
dense: true,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
|
|
class _ConversationItem extends ConsumerWidget {
|
|
final ConversationItem item;
|
|
final WidgetRef ref;
|
|
const _ConversationItem({required this.item, required this.ref});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF8F7FF),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: ListTile(
|
|
leading: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFEDEBFF),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(_getAgentIcon(item.agent), size: 18, color: const Color(0xFF635BFF)),
|
|
),
|
|
title: Text(item.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
|
subtitle: Text(item.lastMessage, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
|
trailing: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 10, color: Color(0xFFCCCCCC))),
|
|
const SizedBox(height: 4),
|
|
PopupMenuButton<int>(
|
|
icon: const Icon(Icons.more_vert, size: 16, color: Color(0xFFCCCCCC)),
|
|
itemBuilder: (_) => [
|
|
const PopupMenuItem(value: 1, child: Text('继续聊')),
|
|
const PopupMenuItem(value: 2, child: Text('删除')),
|
|
],
|
|
onSelected: (v) async {
|
|
if (v == 1) {
|
|
ref.read(chatProvider.notifier).setAgent(item.agent);
|
|
Navigator.pop(context);
|
|
} else if (v == 2) {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('删除对话'),
|
|
content: const Text('确定删除该对话?'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
|
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
|
|
],
|
|
),
|
|
);
|
|
if (ok == true) {
|
|
ref.invalidate(conversationListProvider);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
onTap: () {
|
|
ref.read(chatProvider.notifier).setAgent(item.agent);
|
|
Navigator.pop(context);
|
|
},
|
|
dense: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
IconData _getAgentIcon(ActiveAgent agent) {
|
|
switch (agent) {
|
|
case ActiveAgent.health: return Icons.health_and_safety;
|
|
case ActiveAgent.diet: return Icons.restaurant;
|
|
case ActiveAgent.medication: return Icons.medication;
|
|
case ActiveAgent.report: return Icons.file_open;
|
|
case ActiveAgent.exercise: return Icons.directions_run;
|
|
case ActiveAgent.consultation: return Icons.chat;
|
|
default: return Icons.chat_bubble_outline;
|
|
}
|
|
}
|
|
|
|
String _formatTime(DateTime time) {
|
|
final now = DateTime.now();
|
|
final diff = now.difference(time);
|
|
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
|
|
if (diff.inHours < 24) return '${diff.inHours}小时前';
|
|
if (diff.inDays < 7) return '${diff.inDays}天前';
|
|
return '${time.month}/${time.day}';
|
|
}
|
|
}
|