Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 78573eaa5f fix: VLM 参数优化 - temperature 0.7, top_p 0.8, 指令放 system+user
- VisionAsync 新增 Temperature=0.7, TopP=0.8
- system prompt 用专业营养识别指令
- userText 用简短"请看图识别食物"配合图片
- 修复重复 prompt 导致 VLM 误读文本的 bug
2026-06-03 11:12:06 +08:00

228 lines
14 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 '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/data_providers.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 auth = ref.watch(authProvider);
final user = auth.user;
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final selectedAgent = ref.watch(selectedAgentProvider);
return Scaffold(
drawer: const HealthDrawer(),
backgroundColor: const Color(0xFFF8F7FF),
body: SafeArea(
child: Stack(children: [
Column(children: [
_buildHeader(user),
if (_taskCardsExpanded) _buildTaskCards(),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
_buildInputBar(context),
SizedBox(height: bottomInset > 0 ? bottomInset : 0),
]),
_buildExpandButton(),
]),
),
);
}
Widget _buildHeader(dynamic user) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(children: [
Builder(builder: (ctx) => GestureDetector(
onTap: () => Scaffold.of(ctx).openDrawer(),
child: CircleAvatar(radius: 22, backgroundColor: const Color(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 26, color: Color(0xFF635BFF)) : null),
)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 18, color: const Color(0xFF635BFF)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
const SizedBox(height: 2),
Text('${_getGreeting()}${user?.name ?? '张三'}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
])),
Icon(Icons.notifications_none, size: 24, color: Colors.grey[600]),
]),
);
}
String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 18) return '下午好';
return '晚上好';
}
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 _buildTaskCards() {
final latestHealth = ref.watch(latestHealthProvider);
return latestHealth.when(
data: (data) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [Text('今日任务', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Text('全部展开', style: TextStyle(fontSize: 13, color: const Color(0xFF635BFF))))]),
const SizedBox(height: 12), ..._getTodayTasks(data),
]),
),
loading: () => const SizedBox.shrink(),
error: (_, __) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [const Text('今日任务', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Text('全部展开', style: TextStyle(fontSize: 13, color: const Color(0xFF635BFF))))]),
const SizedBox(height: 12), ..._getTodayTasks({}),
],
),
),
);
}
List<Widget> _getTodayTasks(Map<String, dynamic> healthData) {
return [
_taskRow(icon: Icons.medication_rounded, label: '计划 8:00 吃 阿司匹林 100mg', status: 'done', onTap: _handleMedicationCheck),
_taskRow(icon: Icons.directions_run, label: '今日待运动:散步 30 分钟', status: 'pending', onTap: null),
_taskRow(icon: Icons.today, label: '今日测量:血压', status: 'pending', onTap: () => _textCtrl.text = '血压 '),
..._buildAbnormalRows(healthData),
];
}
List<Widget> _buildAbnormalRows(Map<String, dynamic> healthData) {
final rows = <Widget>[];
final bp = healthData['BloodPressure'];
if (bp is Map) { final s = bp['systolic']; if (s is int && s >= 140) rows.add(_taskRow(icon: Icons.warning_amber_rounded, label: '血压 $s/${bp['diastolic'] ?? '--'} 偏高', status: 'warning', onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}))); }
return rows;
}
Widget _taskRow({required IconData icon, required String label, required String status, VoidCallback? onTap}) {
final colors = {'done': const Color(0xFF43A047), 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E)};
final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Row(children: [
Container(width: 32, height: 32, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 16, color: const Color(0xFF635BFF))),
const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF333333)))),
Icon(icons[status], size: 20, color: colors[status] ?? Colors.grey),
]),
),
);
}
void _handleMedicationCheck() async {
await ref.read(medicationServiceProvider).confirm('');
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF635BFF)));
}
Widget _buildAgentPanel(BuildContext context, 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 AnimatedContainer(duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 12, offset: const Offset(0, -4))]), child: Column(mainAxisSize: MainAxisSize.min, children: [
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(height: 4), Text(tips[agent] ?? '', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: _getAgentButtons(agent)),
]));
}
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 ElevatedButton.icon(
onPressed: () => _onAgentAction(label),
icon: Icon(icon, size: 16),
label: Text(label, style: const TextStyle(fontSize: 13)),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFF5F3FF), foregroundColor: const Color(0xFF635BFF), elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14)),
);
}
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 = '[图片已上传]'; 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(BuildContext context) {
return Container(padding: const EdgeInsets.symmetric(horizontal: 12, 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, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none), onSubmitted: (_) => _sendMessage())),
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
]));
}
}