Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

## 前端认证与媒体
- 新增 authenticated_network_image.dart: 带认证的图片加载组件
- auth_provider: 认证状态管理大幅增强(+173)
- api_client: 网络客户端增强(+124)
- chat_provider: 聊天 provider 重构(+76)
- omron_device_provider: 蓝牙设备 provider 增强(+53)
- sse_handler: SSE 处理增强(+35)
- consultation_provider / data_providers / conversation_history_provider: 调整

## 页面调整
- remaining_pages: 健康档案/饮食记录等页面增强(+115)
- home_page / chat_messages_view: 主页微调
- doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports)
- report_pages / settings_pages / notification_prefs_page: 微调
- device_scan_page / diet_capture_page / admin_home_page: 微调

## 测试
- 新增 file_path_security_tests: 文件路径安全测试
- 新增 protected_media_url_test: 媒体URL保护测试
- 新增 user_session_identity_test: 用户会话身份测试
- account_deletion_tests / application_service_tests / auth_tests: 更新
2026-07-20 10:19:01 +08:00

708 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 'dart:io';
import 'dart:async';
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 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.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/keyboard_lift.dart';
import '../diet/diet_capture_page.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>
with WidgetsBindingObserver {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
final _focusNode = FocusNode();
final _scaffoldKey = GlobalKey<ScaffoldState>();
String? _pickedImagePath;
Timer? _notificationTimer;
double _conversationDragDistance = 0;
void _startConversationDrawerDrag(DragStartDetails details) {
_conversationDragDistance = 0;
}
void _updateConversationDrawerDrag(DragUpdateDetails details) {
_conversationDragDistance += details.delta.dx;
if (_conversationDragDistance < 0) {
_conversationDragDistance = 0;
}
if (_conversationDragDistance >= 42) {
_conversationDragDistance = 0;
_scaffoldKey.currentState?.openDrawer();
}
}
void _endConversationDrawerDrag(DragEndDetails details) {
_conversationDragDistance = 0;
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scheduleScrollToLatest();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkDueNotifications();
_refreshTodayHealthIfCached();
});
_notificationTimer = Timer.periodic(
const Duration(minutes: 2),
(_) => _checkDueNotifications(),
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkDueNotifications();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_notificationTimer?.cancel();
_textCtrl.dispose();
_scrollCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
void _sendMessage() {
if (ref.read(chatProvider).isStreaming) return;
final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear();
_dismissKeyboard();
setState(() => _pickedImagePath = null);
if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text);
} else {
ref.read(chatProvider.notifier).sendMessage(text);
}
}
void _refreshTodayHealthIfCached() {
if (!ref.read(todayHealthSnapshotProvider).hasValue) return;
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationReminderProvider);
ref.invalidate(currentExercisePlanProvider);
}
void _scheduleScrollToLatest() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
// Lazy list items can refine the final extent after the first layout.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
});
});
}
@override
Widget build(BuildContext context) {
final auth = ref.watch(authProvider);
final user = auth.user;
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();
}
});
ref.listen(dietActionProvider, (prev, next) {
if (next == 'pickFoodCamera') {
_pickFoodImage(ImageSource.camera);
ref.read(dietActionProvider.notifier).clear();
} else if (next == 'pickFoodGallery') {
_pickFoodImage(ImageSource.gallery);
ref.read(dietActionProvider.notifier).clear();
}
});
ref.listen<int>(chatProvider.select((state) => state.messages.length), (
previous,
current,
) {
if (current <= (previous ?? 0)) return;
_scheduleScrollToLatest();
});
return Scaffold(
key: _scaffoldKey,
resizeToAvoidBottomInset: false,
backgroundColor: const Color(0xFFFFFCFF),
drawer: const HealthDrawer(),
drawerEnableOpenDragGesture: false,
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
),
),
child: SafeArea(
child: Column(
children: [
_buildHeader(user),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: _startConversationDrawerDrag,
onHorizontalDragUpdate: _updateConversationDrawerDrag,
onHorizontalDragEnd: _endConversationDrawerDrag,
onHorizontalDragCancel: () => _conversationDragDistance = 0,
child: RepaintBoundary(
child: _HomeMessages(scrollCtrl: _scrollCtrl),
),
),
),
KeyboardLift(child: _buildBottomBar(context)),
],
),
),
),
);
}
Widget _buildHeader(dynamic user) {
final name = (user?.name != null && user!.name!.isNotEmpty)
? user.name
: '用户';
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
return Container(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 6),
child: Row(
children: [
Builder(
builder: (ctx) {
return _HeaderIconButton(
icon: LucideIcons.menu,
onTap: () => Scaffold.of(ctx).openDrawer(),
);
},
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'小脉健康',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textHint,
),
),
const SizedBox(height: 2),
Text(
'${_getGreeting()}$name',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
),
_HeaderIconButton(
icon: LucideIcons.bell,
badgeCount: unreadCount,
onTap: () => pushRoute(ref, 'notifications'),
),
],
),
);
}
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,
ActiveAgent.health,
ActiveAgent.diet,
ActiveAgent.medication,
ActiveAgent.report,
ActiveAgent.exercise,
];
Widget _buildAgentBar() {
final activeAgent = ref.watch(
chatProvider.select((state) => state.activeAgent),
);
return SizedBox(
height: 46,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
itemCount: _agentDefs.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final agent = _agentDefs[i];
final visual = _agentVisual(agent);
final selected = activeAgent == agent;
return GestureDetector(
onTap: () => ref
.read(chatProvider.notifier)
.triggerAgent(agent, visual.label),
child: Container(
decoration: BoxDecoration(
color: selected ? null : Colors.transparent,
gradient: selected ? AppColors.actionOutlineGradient : null,
borderRadius: AppRadius.pillBorder,
),
padding: const EdgeInsets.all(1.2),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 11.8,
vertical: 7.8,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
gradient: visual.gradient,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
visual.icon,
size: 13,
color: visual.iconColor,
),
),
const SizedBox(width: 6),
Text(
visual.label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
),
);
},
),
);
}
({String label, IconData icon, LinearGradient gradient, Color iconColor})
_agentVisual(ActiveAgent agent) {
({String label, AppModuleVisual visual}) fromModule(
String label,
AppModuleVisual visual,
) => (label: label, visual: visual);
final module = switch (agent) {
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
ActiveAgent.report => fromModule('报告', AppModuleVisuals.report),
ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise),
_ => null,
};
if (module != null) {
return (
label: module.label,
icon: module.visual.icon,
gradient: module.visual.gradient,
iconColor: Colors.white,
);
}
return switch (agent) {
ActiveAgent.consultation => (
label: 'AI问诊',
icon: LucideIcons.messageCircle,
gradient: AppColors.doctorGradient,
iconColor: Colors.white,
),
_ => (
label: 'AI问诊',
icon: LucideIcons.messageCircle,
gradient: AppColors.primaryGradient,
iconColor: Colors.white,
),
};
}
Widget _buildBottomBar(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.28),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
_buildAgentBar(),
const SizedBox(height: 12),
if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(),
],
),
);
}
Widget _buildImagePreview() {
return Container(
margin: const EdgeInsets.fromLTRB(14, 8, 14, 0),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(
File(_pickedImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'点击发送图片',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
),
GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
),
],
),
);
}
Widget _buildInputBar() {
final isStreaming = ref.watch(
chatProvider.select((state) => state.isStreaming),
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
child: Container(
padding: const EdgeInsets.fromLTRB(6, 5, 6, 5),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.pillBorder,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Material(
color: Colors.transparent,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _showAttachmentPicker(context),
child: Ink(
width: 38,
height: 38,
decoration: const BoxDecoration(
gradient: AppColors.primaryGradient,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.plus,
size: 21,
color: Colors.white,
),
),
),
),
const SizedBox(width: 6),
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 112),
child: TextField(
controller: _textCtrl,
focusNode: _focusNode,
style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
),
maxLines: null,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: '输入你想说的...',
filled: false,
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 12,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
onSubmitted: (_) => _sendMessage(),
),
),
),
const SizedBox(width: 6),
Material(
color: Colors.transparent,
child: InkWell(
customBorder: const CircleBorder(),
onTap: isStreaming
? () => ref.read(chatProvider.notifier).stopGenerating()
: _sendMessage,
child: Ink(
width: 38,
height: 38,
decoration: const BoxDecoration(
gradient: AppColors.primaryGradient,
shape: BoxShape.circle,
),
child: Icon(
isStreaming ? Icons.stop_rounded : LucideIcons.send,
size: 18,
color: Colors.white,
),
),
),
),
],
),
),
);
}
void _showAttachmentPicker(BuildContext context) {
_dismissKeyboard();
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Wrap(
children: [
ListTile(
leading: const Icon(
Icons.camera_alt_outlined,
color: AppColors.primary,
),
title: const Text('拍照'),
onTap: () {
Navigator.pop(ctx);
_dismissKeyboard();
_pickImage(ImageSource.camera);
},
),
ListTile(
leading: const Icon(
Icons.photo_library_outlined,
color: AppColors.primary,
),
title: const Text('从相册选择'),
onTap: () {
Navigator.pop(ctx);
_dismissKeyboard();
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(
Icons.picture_as_pdf_outlined,
color: AppColors.primary,
),
title: const Text('上传 PDF'),
onTap: () async {
Navigator.pop(ctx);
await _pickPdf();
},
),
],
),
),
),
);
}
Future<void> _pickImage(ImageSource source) async {
_dismissKeyboard();
final picked = await ImagePicker().pickImage(
source: source,
imageQuality: 85,
);
_dismissKeyboard();
if (picked != null) {
final token = await ref.read(apiClientProvider).accessToken;
if (token == null) return;
setState(() => _pickedImagePath = picked.path);
WidgetsBinding.instance.addPostFrameCallback((_) => _dismissKeyboard());
}
}
void _dismissKeyboard() {
_focusNode.unfocus();
FocusManager.instance.primaryFocus?.unfocus();
}
Future<void> _pickPdf() async {
_dismissKeyboard();
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
withData: false,
);
if (result == null || result.files.isEmpty) return;
final pdfFile = result.files.first;
final path = pdfFile.path;
if (path == null || path.isEmpty) return;
await ref
.read(chatProvider.notifier)
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
_textCtrl.clear();
_dismissKeyboard();
}
Future<void> _checkDueNotifications() async {
try {
await ref.read(inAppNotificationServiceProvider).checkDue();
} catch (_) {
// Network failures should not block the home page.
} finally {
ref.invalidate(notificationUnreadCountProvider);
}
}
Future<void> _pickFoodImage(ImageSource source) async {
final picked = await ImagePicker().pickImage(
source: source,
imageQuality: 80,
maxWidth: 1024,
maxHeight: 1024,
);
if (picked != null && mounted) {
ref.read(dietProvider.notifier).reset();
ref.read(dietProvider.notifier).setImage(picked.path);
ref.read(dietProvider.notifier).analyzeImage();
pushRoute(ref, 'dietCapture');
}
}
}
class _HomeMessages extends ConsumerWidget {
final ScrollController scrollCtrl;
const _HomeMessages({required this.scrollCtrl});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(chatProvider.select((state) => state.messages));
return ChatMessagesView(scrollCtrl: scrollCtrl, messages: messages);
}
}
class _HeaderIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final int badgeCount;
const _HeaderIconButton({
required this.icon,
required this.onTap,
this.badgeCount = 0,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Icon(icon, size: 20, color: AppColors.textPrimary),
),
if (badgeCount > 0)
Positioned(
right: -5,
top: -5,
child: Container(
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 2),
),
alignment: Alignment.center,
child: Text(
badgeCount > 99 ? '99+' : '$badgeCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
}