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_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 '../../services/in_app_notification_service.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 { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); final _focusNode = FocusNode(); String? _pickedImagePath; int _lastMsgCount = 0; Timer? _notificationTimer; bool _checkingNotifications = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications()); _notificationTimer = Timer.periodic( const Duration(seconds: 30), (_) => _checkNotifications(), ); } @override void dispose() { _notificationTimer?.cancel(); _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); } Future _checkNotifications() async { if (!mounted || _checkingNotifications) return; if (!ref.read(authProvider).isLoggedIn) return; _checkingNotifications = true; try { final service = InAppNotificationService(ref.read(apiClientProvider)); final notifications = await service.getPending(); for (final notification in notifications) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 5), content: Row( children: [ Icon( notification.type == 'ExerciseReminder' ? Icons.directions_run_outlined : Icons.medication_outlined, color: Colors.white, ), const SizedBox(width: 12), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(notification.title, style: const TextStyle(fontWeight: FontWeight.w700)), Text(notification.message), ], ), ), ], ), ), ); await service.acknowledge(notification.id); } } catch (_) { // App 内提醒失败不阻断主页使用,下次轮询会重试。 } finally { _checkingNotifications = false; } } void _sendMessage() { final text = _textCtrl.text.trim(); final imagePath = _pickedImagePath; if (text.isEmpty && imagePath == null) return; _textCtrl.clear(); _focusNode.unfocus(); 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 chatState = ref.watch(chatProvider); 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(); } }); final currentCount = chatState.messages.length; if (currentCount > _lastMsgCount) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollCtrl.hasClients) { _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); } }); } _lastMsgCount = currentCount; return Scaffold( drawer: const HealthDrawer(), body: AppBackground( safeArea: true, child: Column( children: [ _buildHeader(user), Expanded( child: ChatMessagesView( scrollCtrl: _scrollCtrl, messages: chatState.messages, ), ), _buildBottomBar(context), ], ), ), ); } Widget _buildHeader(dynamic user) { final name = (user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'; 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( 'AI 健康管家', 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, onTap: () => pushRoute(ref, 'notificationPrefs'), ), ], ), ); } 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, 'AI问诊', LucideIcons.messageCircle), (ActiveAgent.health, '记数据', LucideIcons.heartPulse), (ActiveAgent.diet, '拍饮食', LucideIcons.utensils), (ActiveAgent.medication, '药管家', LucideIcons.pill), (ActiveAgent.report, '报告分析', LucideIcons.fileText), (ActiveAgent.exercise, '运动', LucideIcons.activity), ]; Widget _buildAgentBar() { return SizedBox( height: 42, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 14), itemCount: _agentDefs.length, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (_, i) { final (agent, label, icon) = _agentDefs[i]; final gradient = _agentGradient(agent); return GestureDetector( onTap: () => ref.read(chatProvider.notifier).triggerAgent(agent, label), child: Container( padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.86), borderRadius: BorderRadius.circular(999), border: Border.all(color: Colors.white, width: 1.2), boxShadow: [ BoxShadow( color: const Color(0xFF6366F1).withValues(alpha: 0.10), blurRadius: 14, offset: const Offset(0, 7), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 16, height: 16, decoration: BoxDecoration( gradient: gradient, borderRadius: BorderRadius.circular(6), ), child: Icon(icon, size: 11, color: Colors.white), ), const SizedBox(width: 6), Text( label, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ], ), ), ); }, ), ); } LinearGradient _agentGradient(ActiveAgent agent) { return switch (agent) { ActiveAgent.consultation => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)], ), ActiveAgent.health => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFFFAFBD), Color(0xFFC9FFBF)], ), ActiveAgent.diet => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFF6D365), Color(0xFFFDA085)], ), ActiveAgent.medication => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)], ), ActiveAgent.report => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)], ), ActiveAgent.exercise => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)], ), _ => 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)), ), boxShadow: [ BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.045), blurRadius: 20, offset: const Offset(0, -8), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 6), _buildAgentBar(), const SizedBox(height: 6), 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) { 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); _pickImage(ImageSource.camera); }, ), ListTile( leading: const Icon( Icons.photo_library_outlined, color: AppColors.primary, ), title: const Text('从相册选择'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }, ), ListTile( leading: const Icon( Icons.file_open_outlined, color: AppColors.primary, ), 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(() {}); } }, ), ], ), ), ), ); } Future _pickImage(ImageSource source) async { final picked = await ImagePicker().pickImage( source: source, imageQuality: 85, ); if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; setState(() => _pickedImagePath = picked.path); } } 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 _HeaderIconButton extends StatelessWidget { final IconData icon; final VoidCallback onTap; const _HeaderIconButton({required this.icon, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: 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), ), ); } } 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), ), ); } }