Files
app/lib/presentation/pages/shell/app_shell.dart
T
gongyun b02daf1448 主要合入内容:
- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、Ctrl/Cmd+F搜索、Esc 关闭搜索、鼠标右滑返回上级。
- 移动端同步状态页、同步统计卡片、同步任务/累计统计事件、同步页布局更新。
- Android 相册同步默认路径改为 DCIM/Camera,下载路径改用 external_path。
- Rust sync-core 更新:Android 日志依赖、AlbumUpload/AlbumDownload、MirrorWcf 统计修复、Linux FUSE镜像挂载与读写同步支持。

- 依赖更新:external_path、fl_chart、pdfrx 升级,并修了 pdfrx 新版本的deprecated API。
- 新增文件包括:sort_options.dart、sync_page_android.dart、sync_stats_card.dart、platform/fuse.rs、sync_engine/fuse.rs。
2026-06-04 07:11:43 +08:00

640 lines
21 KiB
Dart

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<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell>
with GestureHandlerMixin, TickerProviderStateMixin, WidgetsBindingObserver {
final Set<int> _visitedPageIndexes = <int>{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<Widget> _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<AuthProvider>().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<NavigationProvider>(context, listen: false);
nav.setIndex(index);
final userSetting = Provider.of<UserSettingProvider>(
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<FileManagerProvider>(
context,
listen: false,
);
final userSetting = Provider.of<UserSettingProvider>(
context,
listen: false,
);
final admin = Provider.of<AdminProvider>(context, listen: false);
final sync = Provider.of<SyncProvider>(context, listen: false);
fileManager.clearFiles();
userSetting.clear();
admin.clear();
if (sync.engineInitialized) {
sync.resetSync();
}
final nav = Provider.of<NavigationProvider>(context, listen: false);
nav.setIndex(0);
userSetting.loadCapacity();
}
Future<void> _showPostLoginAnnouncement() async {
final authProvider = context.read<AuthProvider>();
if (!authProvider.isAuthenticated) return;
try {
final service = AnnouncementService.instance;
final announcement = await service.getChangedSiteNotice();
if (!mounted || announcement == null) return;
await DialogQueueService.instance.enqueue<void>(() 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<void> _checkClipboardShareLink() async {
await Future<void>.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<void>(() async {
if (!mounted) return;
final open = await showDialog<bool>(
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<NavigationProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
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<AuthProvider, NavigationProvider>(
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<SyncProvider>(
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<UploadManagerProvider, DownloadManagerProvider>(
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<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: false, size: 24),
);
},
),
selectedIcon: Consumer<SyncProvider>(
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<AuthProvider>();
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<UploadManagerProvider, DownloadManagerProvider>(
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<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: false, size: 24),
);
},
),
selectedIcon: Consumer<SyncProvider>(
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<void> _handleLogout(BuildContext context) async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final confirmed = await showDialog<bool>(
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);
}
}
}
}