Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 8dcf99cac5 style: 全项目紫色→薄荷绿 Fresh Air 清新风
- 主色 #635BFF→#14B8A6 (薄荷绿)
- 浅紫 #EDEBFF→#E6FAF6 (极浅薄荷)
- 深紫 #4B44D6→#0F9D8E (深薄荷)
- 渐变紫→薄荷渐变
- 全局13种紫色映射替换
2026-06-03 20:30:28 +08:00

483 lines
20 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 '../../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;
String? _pickedImagePath;
double _lastScrollOffset = 0;
DateTime? _lastCollapseTime;
bool _exerciseDone = false;
final Set<ActiveAgent> _welcomedAgents = {};
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); _textCtrl.addListener(_onTextChange); }
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
void _onTextChange() {
if (_textCtrl.text.isNotEmpty && _taskCardsExpanded) {
setState(() => _taskCardsExpanded = false);
}
}
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();
final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear();
setState(() { _taskCardsExpanded = false; _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(0xFFF6F9FB),
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(0xFFE6FAF6), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF14B8A6)) : 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(0xFF14B8A6)), 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.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF14B8A6).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]),
child: Row(children: [
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Spacer(),
const Text('展开 ▾', style: TextStyle(fontSize: 12, color: Color(0xFF14B8A6))),
]),
),
);
}
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(0xFF14B8A6).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: [
const Text('收起', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
const Text('', style: TextStyle(fontSize: 12, color: 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 _taskRow(
icon: Icons.event_available,
label: '📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}',
status: 'pending',
onTap: () => pushRoute(ref, 'followups'),
);
}
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(crossAxisAlignment: CrossAxisAlignment.center, children: [
Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF2FAF9), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF14B8A6))),
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('');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF14B8A6)));
}
// ═════════════════════ 智能体选择条(常驻) ═════════════════════
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: (_, __) => 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(0xFF14B8A6) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isActive ? const Color(0xFF14B8A6) : 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(0xFF14B8A6)), 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(() {}); }}),
])));
}
}