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_module_visuals.dart'; import '../../core/app_theme.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 '../diet/diet_capture_page.dart'; import 'widgets/chat_messages_view.dart'; class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override ConsumerState createState() => _HomePageState(); } class _HomePageState extends ConsumerState with WidgetsBindingObserver { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); final _focusNode = FocusNode(); final _scaffoldKey = GlobalKey(); double? _drawerDragStartX; String? _pickedImagePath; Timer? _notificationTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback( (_) => ref.invalidate(notificationUnreadCountProvider), ); _notificationTimer = Timer.periodic( const Duration(minutes: 2), (_) => ref.invalidate(notificationUnreadCountProvider), ); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { ref.invalidate(notificationUnreadCountProvider); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _notificationTimer?.cancel(); _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); } void _sendMessage() { 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); } } @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(chatProvider.select((state) => state.messages.length), ( previous, current, ) { if (current <= (previous ?? 0)) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _scrollCtrl.hasClients) { _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); } }); }); return Scaffold( key: _scaffoldKey, backgroundColor: const Color(0xFFFFFCFF), drawer: const HealthDrawer(), drawerEnableOpenDragGesture: false, body: AppBackground( safeArea: true, child: Column( children: [ _buildHeader(user), Expanded( child: Stack( children: [ RepaintBoundary( child: _HomeMessages(scrollCtrl: _scrollCtrl), ), Positioned( left: 0, top: 0, bottom: 0, width: MediaQuery.sizeOf(context).width * 0.8, child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) { _drawerDragStartX = details.globalPosition.dx; }, onHorizontalDragUpdate: (details) { final startX = _drawerDragStartX; if (startX == null) return; if (details.globalPosition.dx - startX < 28) return; _drawerDragStartX = null; _scaffoldKey.currentState?.openDrawer(); }, onHorizontalDragEnd: (_) => _drawerDragStartX = null, onHorizontalDragCancel: () => _drawerDragStartX = null, ), ), ], ), ), _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, 10, 14, 10), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.34), border: Border( bottom: BorderSide(color: Colors.white.withValues(alpha: 0.62)), ), boxShadow: [ BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.025), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Row( children: [ Builder( builder: (ctx) { return _HeaderIconButton( icon: LucideIcons.menu, onTap: () => Scaffold.of(ctx).openDrawer(), ); }, ), const SizedBox(width: 12), 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.w800, 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( padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(999), border: Border.all( color: selected ? AppColors.auraIndigo.withValues(alpha: 0.42) : const Color(0xFFD5DAE4), width: selected ? 1.4 : 1.1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 16, height: 16, decoration: BoxDecoration( gradient: visual.gradient, borderRadius: BorderRadius.circular(6), ), child: Icon(visual.icon, size: 11, color: Colors.white), ), const SizedBox(width: 6), Text( visual.label, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ], ), ), ); }, ), ); } ({String label, IconData icon, LinearGradient gradient}) _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, ); } return switch (agent) { ActiveAgent.consultation => ( label: 'AI问诊', icon: LucideIcons.messageCircle, gradient: AppColors.doctorGradient, ), _ => ( label: 'AI问诊', icon: LucideIcons.messageCircle, gradient: AppColors.primaryGradient, ), }; } 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() { return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ _RoundToolButton( icon: LucideIcons.plus, onTap: () => _showAttachmentPicker(context), ), const SizedBox(width: 8), Expanded( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 118), child: TextField( controller: _textCtrl, focusNode: _focusNode, style: const TextStyle( fontSize: 15, color: AppColors.textPrimary, ), maxLines: null, textInputAction: TextInputAction.newline, decoration: InputDecoration( hintText: '输入你想说的...', filled: true, fillColor: Colors.white, contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 11, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(22), borderSide: const BorderSide(color: AppColors.borderLight), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(22), borderSide: const BorderSide(color: AppColors.borderLight), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(22), borderSide: const BorderSide( color: AppColors.auraIndigo, width: 1.2, ), ), ), onSubmitted: (_) => _sendMessage(), ), ), ), const SizedBox(width: 8), GestureDetector( onTap: _sendMessage, child: Container( width: 42, height: 42, margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2), decoration: BoxDecoration( gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(21), boxShadow: AppColors.buttonShadow, ), child: const Icon( LucideIcons.send, size: 19, 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); _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; // 上传 + 让 AI 看 PDF 内容 await ref .read(chatProvider.notifier) .sendPdf(path, pdfFile.name, _textCtrl.text.trim()); _textCtrl.clear(); _dismissKeyboard(); if (mounted) setState(() {}); }, ), ], ), ), ), ); } Future _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 _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: 38, height: 38, 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.w800, ), ), ), ), ], ), ); } } class _RoundToolButton extends StatelessWidget { final IconData icon; final VoidCallback onTap; const _RoundToolButton({required this.icon, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: 42, height: 42, margin: const EdgeInsets.only(bottom: 2), decoration: BoxDecoration( gradient: AppColors.surfaceGradient, borderRadius: BorderRadius.circular(21), border: Border.all(color: AppColors.borderLight), boxShadow: AppColors.cardShadowLight, ), child: Icon(icon, size: 21, color: AppColors.primaryDark), ), ); } }