import 'dart:io'; import 'dart:async'; import 'package:flutter/gestures.dart'; 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 '../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(); String? _pickedImagePath; Timer? _notificationTimer; @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() { 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(chatProvider.select((state) => state.messages.length), ( previous, current, ) { if (current <= (previous ?? 0)) return; _scheduleScrollToLatest(); }); return Scaffold( key: _scaffoldKey, backgroundColor: const Color(0xFFFFFCFF), drawer: const HealthDrawer(), drawerEnableOpenDragGesture: true, drawerDragStartBehavior: DragStartBehavior.down, drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.8, 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: RepaintBoundary( child: _HomeMessages(scrollCtrl: _scrollCtrl), ), ), _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.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( 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: Colors.white), ), const SizedBox(width: 6), Text( visual.label, style: const TextStyle( fontSize: 15, 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: 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: _sendMessage, child: Ink( width: 38, height: 38, decoration: const BoxDecoration( gradient: AppColors.primaryGradient, shape: BoxShape.circle, ), child: const Icon( 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 _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 _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 _checkDueNotifications() async { try { await ref.read(inAppNotificationServiceProvider).checkDue(); } catch (_) { // Network failures should not block the home page. } finally { ref.invalidate(notificationUnreadCountProvider); } } 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: 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.w800, ), ), ), ), ], ), ); } }