import 'package:cloudreve4_flutter/presentation/providers/admin_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/sync_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart'; import 'package:cloudreve4_flutter/presentation/widgets/announcement_dialog.dart'; import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart'; import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart'; import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart'; import 'package:cloudreve4_flutter/services/announcement_service.dart'; import 'package:cloudreve4_flutter/services/dialog_queue_service.dart'; import 'package:cloudreve4_flutter/services/share_link_service.dart'; import 'package:cloudreve4_flutter/presentation/pages/share/share_link_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../../router/app_router.dart'; import '../files/files_page.dart'; import '../overview/overview_page.dart'; import '../sync/sync_page.dart'; import '../tasks/tasks_page.dart'; import '../profile/profile_page.dart'; class _ShellPageSlot extends StatefulWidget { final Widget child; const _ShellPageSlot({required this.child}); @override State<_ShellPageSlot> createState() => _ShellPageSlotState(); } class _ShellPageSlotState extends State<_ShellPageSlot> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); return widget.child; } } class AppShell extends StatefulWidget { const AppShell({super.key}); @override State createState() => _AppShellState(); } class _AppShellState extends State with GestureHandlerMixin, TickerProviderStateMixin, WidgetsBindingObserver { final Set _visitedPageIndexes = {0}; late AnimationController _syncSpinController; String? _lastClipboardShareId; String? _lastUserId; bool _cachedShowSyncTab = false; static bool _shouldShowSyncTab(double screenWidth) { if (defaultTargetPlatform != TargetPlatform.android && defaultTargetPlatform != TargetPlatform.iOS) { return true; } return screenWidth >= 800; } List _pages(bool showSyncTab) => showSyncTab ? [ const OverviewPage(), const FilesPage(), const TasksPage(), const SyncPage(), const ProfilePage(), ] : [ const OverviewPage(), const FilesPage(), const TasksPage(), const ProfilePage(), ]; int _clampedIndex(int index) { final maxIndex = _pages(_cachedShowSyncTab).length - 1; if (index < 0) return 0; if (index > maxIndex) return maxIndex; return index; } @override void initState() { super.initState(); _syncSpinController = AnimationController( vsync: this, duration: const Duration(seconds: 2), ); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { _showPostLoginAnnouncement(); _checkClipboardShareLink(); _lastUserId = context.read().user?.id; }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _syncSpinController.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _checkClipboardShareLink(); } } void _handleTabSelected(int index) { final nav = Provider.of(context, listen: false); nav.setIndex(index); final userSetting = Provider.of( context, listen: false, ); if (index == 0 || index == _pages(_cachedShowSyncTab).length - 1) { userSetting.loadCapacity(); } } void _checkUserChange(AuthProvider auth) { final currentUserId = auth.user?.id; if (_lastUserId != null && currentUserId != _lastUserId) { _lastUserId = currentUserId; Future.microtask(() { if (mounted) _resetProvidersOnUserChange(); }); } else if (_lastUserId == null && currentUserId != null) { _lastUserId = currentUserId; } } void _resetProvidersOnUserChange() { final fileManager = Provider.of( context, listen: false, ); final userSetting = Provider.of( context, listen: false, ); final admin = Provider.of(context, listen: false); final sync = Provider.of(context, listen: false); fileManager.clearFiles(); userSetting.clear(); admin.clear(); if (sync.engineInitialized) { sync.resetSync(); } final nav = Provider.of(context, listen: false); nav.setIndex(0); userSetting.loadCapacity(); } Future _showPostLoginAnnouncement() async { final authProvider = context.read(); if (!authProvider.isAuthenticated) return; try { final service = AnnouncementService.instance; final announcement = await service.getChangedSiteNotice(); if (!mounted || announcement == null) return; await DialogQueueService.instance.enqueue(() async { if (!mounted) return; await AnnouncementDialog.show( context, title: announcement.title, html: announcement.html, baseUrl: announcement.baseUrl, ); await service.markDismissed(announcement); }); } catch (_) { // Announcement checks should never block the shell. } } Future _checkClipboardShareLink() async { await Future.delayed(const Duration(milliseconds: 650)); if (!mounted) return; try { final data = await Clipboard.getData(Clipboard.kTextPlain); final candidate = ShareLinkService.instance.parseShareLink(data?.text); if (candidate == null) return; if (_lastClipboardShareId == candidate.id) return; _lastClipboardShareId = candidate.id; await DialogQueueService.instance.enqueue(() async { if (!mounted) return; final open = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('检测到分享链接'), content: Text( '是否打开这个文件分享?\n\n${candidate.url}', maxLines: 5, overflow: TextOverflow.ellipsis, ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('忽略'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('打开'), ), ], ), ); if (open == true && mounted) { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ShareLinkPage(candidate: candidate), ), ); } }); } catch (_) { // Clipboard access failures should not affect the shell. } } @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth >= 1000; _cachedShowSyncTab = _shouldShowSyncTab(screenWidth); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (!didPop) { final navProvider = Provider.of( context, listen: false, ); final fileManager = Provider.of( context, listen: false, ); if (navProvider.currentIndex == 1 && fileManager.currentPath != '/') { await fileManager.goBack(); } else if (navProvider.currentIndex != 0 && navProvider.currentIndex != 1) { navProvider.setIndex(0); } else { await checkExitApp(context); } } }, child: Consumer2( builder: (context, auth, navProvider, _) { _checkUserChange(auth); if (isDesktop) { return _buildDesktopLayout(context, navProvider); } return _buildMobileLayout(context, navProvider); }, ), ); } Widget _buildPageContent(BuildContext context, int currentIndex) { final pages = _pages(_cachedShowSyncTab); final visibleIndex = _clampedIndex(currentIndex); _visitedPageIndexes.add(visibleIndex); return RepaintBoundary( child: IndexedStack( index: visibleIndex, children: List.generate(pages.length, (index) { if (!_visitedPageIndexes.contains(index)) { return const SizedBox.shrink(); } return _ShellPageSlot(child: pages[index]); }), ), ); } Widget _buildSyncIcon({required bool isSelected, required double size}) { return Consumer( builder: (context, sync, _) { final hasWorkers = sync.activeWorkerCount > 0; if (hasWorkers && !_syncSpinController.isAnimating) { _syncSpinController.repeat(); } else if (!hasWorkers && _syncSpinController.isAnimating) { _syncSpinController.stop(); _syncSpinController.value = 0; } final icon = Icon( LucideIcons.refreshCw, size: size, weight: isSelected ? 700 : 400, ); if (!hasWorkers) return icon; return ListenableBuilder( listenable: _syncSpinController, builder: (context, child) { return Transform.rotate( angle: _syncSpinController.value * 2 * 3.14159265, child: child, ); }, child: icon, ); }, ); } Widget _buildMobileLayout( BuildContext context, NavigationProvider navProvider, ) { return Scaffold( body: _buildPageContent(context, navProvider.currentIndex), bottomNavigationBar: GlassmorphismContainer( borderRadius: 0, child: Consumer2( builder: (context, uploadManager, downloadManager, _) { final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount; return NavigationBar( height: 64, selectedIndex: _clampedIndex(navProvider.currentIndex), onDestinationSelected: _handleTabSelected, destinations: [ const NavigationDestination( icon: Icon(LucideIcons.layoutDashboard), selectedIcon: Icon(LucideIcons.layoutDashboard, weight: 700), label: '概览', ), const NavigationDestination( icon: Icon(LucideIcons.folder), selectedIcon: Icon(LucideIcons.folder, weight: 700), label: '文件', ), NavigationDestination( icon: Badge( isLabelVisible: activeCount > 0, label: Text('$activeCount'), child: const Icon(LucideIcons.listChecks), ), selectedIcon: Badge( isLabelVisible: activeCount > 0, label: Text('$activeCount'), child: const Icon(LucideIcons.listChecks, weight: 700), ), label: '任务', ), if (_cachedShowSyncTab) NavigationDestination( icon: Consumer( builder: (context, sync, _) { final count = sync.activeWorkerCount; return Badge( isLabelVisible: count > 0, label: Text('$count'), child: _buildSyncIcon(isSelected: false, size: 24), ); }, ), selectedIcon: Consumer( builder: (context, sync, _) { final count = sync.activeWorkerCount; return Badge( isLabelVisible: count > 0, label: Text('$count'), child: _buildSyncIcon(isSelected: true, size: 24), ); }, ), label: '同步', ), const NavigationDestination( icon: Icon(LucideIcons.user), selectedIcon: Icon(LucideIcons.user, weight: 700), label: '我的', ), ], ); }, ), ), ); } Widget _buildDesktopLayout( BuildContext context, NavigationProvider navProvider, ) { final theme = Theme.of(context); final authProvider = context.watch(); final user = authProvider.user; final displayName = user?.nickname ?? '用户'; return Scaffold( body: Row( children: [ NavigationRail( selectedIndex: _clampedIndex(navProvider.currentIndex), onDestinationSelected: _handleTabSelected, leading: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: GestureDetector( onTap: () => navProvider.setIndex(_pages(_cachedShowSyncTab).length - 1), child: Container( decoration: BoxDecoration( shape: BoxShape.circle, border: navProvider.currentIndex == _pages(_cachedShowSyncTab).length - 1 ? Border.all( color: theme.colorScheme.primary, width: 2.5, ) : null, ), child: UserAvatar( userId: user?.id ?? '', email: user?.email, displayName: displayName, radius: 20, ), ), ), ), destinations: [ const NavigationRailDestination( icon: Icon(LucideIcons.layoutDashboard), selectedIcon: Icon(LucideIcons.layoutDashboard, weight: 700), label: Text('概览'), ), const NavigationRailDestination( icon: Icon(LucideIcons.folder), selectedIcon: Icon(LucideIcons.folder, weight: 700), label: Text('文件'), ), NavigationRailDestination( icon: Consumer2( builder: (context, uploadManager, downloadManager, _) { final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount; return Badge( isLabelVisible: activeCount > 0, label: Text('$activeCount'), child: const Icon(LucideIcons.listChecks), ); }, ), selectedIcon: const Icon(LucideIcons.listChecks, weight: 700), label: const Text('任务'), ), if (_cachedShowSyncTab) NavigationRailDestination( icon: Consumer( builder: (context, sync, _) { final count = sync.activeWorkerCount; return Badge( isLabelVisible: count > 0, label: Text('$count'), child: _buildSyncIcon(isSelected: false, size: 24), ); }, ), selectedIcon: Consumer( builder: (context, sync, _) { final count = sync.activeWorkerCount; return Badge( isLabelVisible: count > 0, label: Text('$count'), child: _buildSyncIcon(isSelected: true, size: 24), ); }, ), label: const Text('同步'), ), const NavigationRailDestination( icon: Icon(LucideIcons.user), selectedIcon: Icon(LucideIcons.user, weight: 700), label: Text('我的'), ), ], trailing: Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ const Divider(indent: 12, endIndent: 12), _buildSecondaryNavItem( context, icon: LucideIcons.share2, label: '我的分享', onTap: () => Navigator.of(context).pushNamed(RouteNames.share), ), _buildSecondaryNavItem( context, icon: LucideIcons.cloud, label: 'WebDAV', onTap: () => Navigator.of(context).pushNamed(RouteNames.webdav), ), _buildSecondaryNavItem( context, icon: LucideIcons.download, label: '离线下载', onTap: () => Navigator.of( context, ).pushNamed(RouteNames.remoteDownload), ), _buildSecondaryNavItem( context, icon: LucideIcons.trash2, label: '回收站', onTap: () => Navigator.of(context).pushNamed(RouteNames.recycleBin), ), const Divider(indent: 12, endIndent: 12), _buildSecondaryNavItem( context, icon: LucideIcons.settings, label: '设置', onTap: () => Navigator.of(context).pushNamed(RouteNames.settings), ), _buildSecondaryNavItem( context, icon: LucideIcons.logOut, label: '退出登录', onTap: () => _handleLogout(context), ), const SizedBox(height: 12), ], ), ), ), const VerticalDivider(thickness: 1, width: 1), Expanded(child: _buildPageContent(context, navProvider.currentIndex)), ], ), ); } Widget _buildSecondaryNavItem( BuildContext context, { required IconData icon, required String label, required VoidCallback onTap, }) { final theme = Theme.of(context); return InkWell( onTap: onTap, customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), child: Tooltip( message: label, preferBelow: false, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Icon(icon, size: 22, color: theme.hintColor), ), ), ); } Future _handleLogout(BuildContext context) async { final authProvider = Provider.of(context, listen: false); final fileManager = Provider.of( context, listen: false, ); final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('退出登录'), content: const Text('确定要退出登录吗?'), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('退出'), ), ], ), ); if (confirmed == true) { await authProvider.logout(); fileManager.clearFiles(); if (context.mounted) { Navigator.of( context, ).pushNamedAndRemoveUntil(RouteNames.login, (route) => false); } } } }