Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 287eab80a9 fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒
- SignalR Hub消息持久化+防重复+跨端广播
- 报告VLM+LLM真实AI流程(验证→提取→解读)
- 医生审核页重构(严重程度/建议模板/综合评语)
- 健康概览五合一趋势图(fl_chart+指标切换)
- 用药提醒时区修复+跨午夜+过期提醒
- 运动打卡DayOfWeek映射修复+计划覆盖查询
- 体重与其他四指标同级(Abnormal检查/确认卡牌)
- AI Agent支持多种类多时段批量录入
- 删除硬编码假数据(张三/假医生/假AI解读)
- 随访/报告审核数据链路全部打通
2026-06-07 23:04:23 +08:00

229 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import '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 '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart';
import '../../core/navigation_provider.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();
String? _pickedImagePath;
final Set<ActiveAgent> _welcomedAgents = {};
@override void initState() { super.initState(); }
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
void _sendMessage() {
final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear();
setState(() => _pickedImagePath = null);
if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text);
} else {
ref.read(chatProvider.notifier).sendMessage(text);
}
}
@override Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider);
final auth = ref.watch(authProvider);
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(0xFFF8F9FC),
body: SafeArea(
child: Column(children: [
// ── 顶部栏 ──
_buildHeader(user),
// ── 聊天区域(今日任务在对话流最上方)──
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
// ── 底部合并区 ──
_buildBottomBar(context, selectedAgent),
]),
),
);
}
// ═════════════════════ 顶部栏 ═════════════════════
Widget _buildHeader(dynamic user) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
Builder(builder: (ctx) => GestureDetector(
onTap: () => Scaffold.of(ctx).openDrawer(),
child: CircleAvatar(radius: 20, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF8B9CF7)) : null),
)),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF8B9CF7)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
const SizedBox(height: 2),
Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
])),
Icon(Icons.notifications_none, size: 22, color: Colors.grey[600]),
]),
);
}
String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 18) return '下午好';
return '晚上好';
}
// ═════════════════════ 智能体选择条(常驻) ═════════════════════
static final _agentDefs = [
(ActiveAgent.consultation, '问诊', Icons.chat_bubble_outline),
(ActiveAgent.health, '记数据', Icons.favorite_border),
(ActiveAgent.diet, '拍饮食', Icons.restaurant_outlined),
(ActiveAgent.medication, '药管家', Icons.medication_outlined),
(ActiveAgent.report, '看报告', Icons.description_outlined),
(ActiveAgent.exercise, '运动', Icons.directions_run_outlined),
];
Widget _buildAgentBar(ActiveAgent? selected) {
return Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _agentDefs.length,
separatorBuilder: (_, i) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
final isActive = selected == agent;
return GestureDetector(
onTap: () {
final notifier = ref.read(selectedAgentProvider.notifier);
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(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF8B9CF7) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isActive ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 13, color: isActive ? Colors.white : const Color(0xFF666666)),
const SizedBox(width: 3),
Text(label, style: TextStyle(fontSize: 11, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
]),
),
);
},
),
);
}
// ═════════════════════ 底部合并区:智能体栏 + 操作面板 + 输入框 ═════════════════════
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent) {
return Column(mainAxisSize: MainAxisSize.min, children: [
// 智能体胶囊栏常驻高度36
_buildAgentBar(selectedAgent),
// 图片预览(有选中图片时显示)
if (_pickedImagePath != null) _buildImagePreview(),
// 输入框
_buildCompactInputBar(context),
]);
}
Widget _buildImagePreview() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
child: Row(children: [
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])),
]),
);
}
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(0xFF8B9CF7)), onPressed: _sendMessage),
]),
);
}
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;
setState(() => _pickedImagePath = picked.path);
}
}
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}'; if (mounted) setState(() {}); }}),
])));
}
}