Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 7953cca15d fix: 恢复医生列表/问诊入口
患者端需要:
- 医生列表(找医生)
- 问诊对话页
- 医生用Web端回复,患者用App发起
2026-06-03 15:13:25 +08:00

495 lines
22 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;
double _lastScrollOffset = 0;
DateTime? _lastCollapseTime;
bool _medicationTaken = false;
bool _exerciseDone = false;
static final _mockFollowUps = [
{'hospital': '协和医院', 'department': '心内科', 'date': DateTime.now().add(const Duration(days: 2)), 'type': '复查'},
{'hospital': '人民医院', 'department': '心内科', 'date': DateTime.now().add(const Duration(days: 3)), 'type': '复诊'},
];
@override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); }
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
void _onScroll() {
if (!_scrollCtrl.hasClients) return;
final offset = _scrollCtrl.offset;
if (offset < _lastScrollOffset && _taskCardsExpanded) {
final delta = _lastScrollOffset - offset;
if (delta > 50) {
final now = DateTime.now();
if (_lastCollapseTime == null || now.difference(_lastCollapseTime!) > const Duration(seconds: 2)) {
_lastCollapseTime = now;
setState(() => _taskCardsExpanded = false);
}
}
}
_lastScrollOffset = offset;
}
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 selectedAgent = ref.watch(selectedAgentProvider);
return Scaffold(
drawer: const HealthDrawer(),
backgroundColor: const Color(0xFFF8F7FF),
body: SafeArea(
bottom: false,
child: Column(children: [
// ── 顶部栏 ──
_buildHeader(user),
// ── 今日任务(可折叠) ──
_buildTaskCardsArea(),
// ── 聊天区域(弹性填充剩余空间) ──
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
// ── 智能体选择器(常驻显示) ──
_buildAgentBar(selectedAgent),
// ── 选中智能体的操作面板 ──
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
// ── 输入框 ──
_buildInputBar(context),
]),
),
);
}
// ═════════════════════ 顶部栏 ═════════════════════
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(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF635BFF)) : 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(0xFF635BFF)), 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 ?? '张三'}', 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 '晚上好';
}
// ═════════════════════ 今日任务(可折叠/展开) ═════════════════════
Widget _buildTaskCardsArea() {
final latestHealth = ref.watch(latestHealthProvider);
if (_taskCardsExpanded) {
return latestHealth.when(
data: (data) => _taskCardContent(data),
loading: () => _taskCardContent({}),
error: (_, __) => _taskCardContent({}),
);
}
// 折叠状态:只显示一行可点击的标题栏
return GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = true),
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
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: [
Icon(Icons.assignment_turned_in_outlined, size: 18, color: const Color(0xFF635BFF)),
const SizedBox(width: 8),
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Spacer(),
Text('点击展开', style: TextStyle(fontSize: 12, color: const Color(0xFF635BFF))),
Icon(Icons.keyboard_arrow_right, size: 18, color: const Color(0xFF635BFF)),
]),
),
);
}
Widget _taskCardContent(Map<String, dynamic> healthData) {
return 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: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Spacer(),
GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Row(mainAxisSize: MainAxisSize.min, children: [
Text('收起', style: TextStyle(fontSize: 12, color: const Color(0xFF999999))),
Icon(Icons.keyboard_arrow_up, size: 18, color: const Color(0xFF999999)),
])),
]),
const SizedBox(height: 10),
..._getTodayTasks(healthData),
]),
);
}
List<Widget> _getTodayTasks(Map<String, dynamic> healthData) {
final now = DateTime.now();
final tasks = <Widget>[];
// 1. 数据摘要卡片(有今日指标数据时显示)
final summaryParts = <String>[];
final bp = healthData['BloodPressure'];
if (bp is Map) {
final s = bp['systolic'];
final d = bp['diastolic'];
if (s != null && d != null) summaryParts.add('血压 $s/$d');
}
final hr = healthData['HeartRate'];
if (hr is int) summaryParts.add('心率 $hr');
final bs = healthData['BloodSugar'];
if (bs is num) summaryParts.add('血糖 $bs');
final bo = healthData['BloodOxygen'];
if (bo is num) summaryParts.add('血氧 $bo');
final wt = healthData['Weight'];
if (wt is num) summaryParts.add('体重 $wt');
if (summaryParts.isNotEmpty) {
tasks.add(_summaryCard(summaryParts));
}
// 2. 用药提醒(从后端拉取真实数据)
final reminders = ref.watch(medicationReminderProvider);
reminders.whenData((meds) {
for (final m in meds) {
final name = m['name'] ?? '';
final dosage = m['dosage'] ?? '';
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(', ') ?? '';
final medOverdue = now.hour >= 8;
tasks.add(_taskRow(
icon: Icons.medication_rounded,
label: '$name $dosage ($times)',
status: 'pending',
isOverdue: medOverdue,
onTap: () => _handleMedicationCheck,
));
}
});
// 无提醒时显示默认用药卡片
if (tasks.length <= (summaryParts.isNotEmpty ? 1 : 0)) {
tasks.add(_taskRow(
icon: Icons.medication_rounded,
label: '暂无用药提醒',
status: 'pending',
onTap: null,
));
}
// 3. 运动卡片(超时变红)
final exOverdue = now.hour >= 18 && !_exerciseDone;
tasks.add(_taskRow(
icon: Icons.directions_run,
label: '今日待运动:散步 30 分钟',
status: _exerciseDone ? 'done' : 'pending',
isOverdue: exOverdue,
onTap: _exerciseDone ? null : () => setState(() => _exerciseDone = true),
));
// 4. 测量卡片
tasks.add(_taskRow(
icon: Icons.today,
label: '今日测量:血压',
status: 'pending',
onTap: () => _textCtrl.text = '血压 ',
));
// 5. 异常指标
tasks.addAll(_buildAbnormalRows(healthData));
// 6. 复查提醒未来3天内有复查安排时显示
final upcomingFollowUps = _mockFollowUps.where((f) {
final date = f['date'] as DateTime;
return date.difference(now).inDays <= 3 && date.isAfter(now);
}).toList();
if (upcomingFollowUps.isNotEmpty) {
tasks.add(_followUpCard(upcomingFollowUps.first));
}
return tasks;
}
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 _summaryCard(List<String> parts) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => pushRoute(ref, 'trend'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFF1F8E9),
borderRadius: BorderRadius.circular(10),
),
child: Row(children: [
const Icon(Icons.check_circle, size: 18, color: Color(0xFF43A047)),
const SizedBox(width: 10),
Expanded(child: Text(
'今日已记录:${parts.join('')}',
style: const TextStyle(fontSize: 13, color: Color(0xFF333333)),
)),
]),
),
),
);
}
Widget _followUpCard(Map<String, dynamic> followUp) {
final date = followUp['date'] as DateTime;
final now = DateTime.now();
final diff = date.difference(now).inDays;
String dateLabel;
if (diff == 0) {
dateLabel = '今天';
} else if (diff == 1) {
dateLabel = '明天';
} else if (diff == 2) {
dateLabel = '后天';
} else {
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)),
)),
]),
),
);
}
Widget _taskRow({required IconData icon, required String label, required String status, VoidCallback? onTap, bool isOverdue = false}) {
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};
final effectiveStatus = isOverdue ? 'warning' : status;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(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),
]),
),
),
);
}
void _handleMedicationCheck() async {
await ref.read(medicationServiceProvider).confirm('');
setState(() => _medicationTaken = true);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF635BFF)));
}
// ═════════════════════ 智能体选择条(常驻) ═════════════════════
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: 44,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _agentDefs.length,
separatorBuilder: (_, __) => 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);
notifier.select(isActive ? null : agent);
// 切换智能体时清空聊天
if (!isActive) ref.read(chatProvider.notifier).setAgent(agent);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF635BFF) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isActive ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 14, color: isActive ? Colors.white : const Color(0xFF666666)),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 12, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
]),
),
);
},
),
);
}
// ═════════════════════ 智能体操作面板(选中后显示) ═════════════════════
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 Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
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: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(width: 6),
Expanded(child: Text(tips[agent] ?? '', style: TextStyle(fontSize: 11, color: Colors.grey[500]))),
GestureDetector(onTap: () => ref.read(selectedAgentProvider.notifier).select(null), child: Icon(Icons.close, size: 18, color: Colors.grey[400])),
]),
const SizedBox(height: 8),
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(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 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)),
),
);
}
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(() {}); }
}
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(() {}); }}),
])));
}
Widget _buildInputBar(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
child: Row(children: [
IconButton(icon: const Icon(Icons.attach_file, size: 22, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
Expanded(child: TextField(controller: _textCtrl, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 10), border: InputBorder.none, isDense: true), onSubmitted: (_) => _sendMessage())),
IconButton(icon: const Icon(Icons.send, size: 22, color: Color(0xFF635BFF)), onPressed: _sendMessage),
]),
);
}
}