Files
app/lib/presentation/pages/files/files_page.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

1351 lines
43 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/services.dart';
import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/file_service.dart';
import 'package:cloudreve4_flutter/services/upload_service.dart';
import '../../../core/utils/file_utils.dart';
import '../../../core/constants/sort_options.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/file_manager_provider.dart';
import '../../providers/download_manager_provider.dart';
import '../../providers/upload_manager_provider.dart';
import '../../providers/navigation_provider.dart';
import '../../widgets/file_list_item.dart';
import '../../widgets/file_grid_item.dart';
import '../../widgets/file_list_header.dart';
import '../../widgets/file_breadcrumb.dart';
import '../../widgets/selection_toolbar.dart';
import '../../widgets/empty_folder_view.dart';
import '../../widgets/upload_dialog.dart';
import '../../widgets/file_operation_dialogs.dart';
import '../../widgets/file_info_dialog.dart';
import '../../widgets/search_dialog.dart';
import '../../widgets/toast_helper.dart';
import '../../../router/app_router.dart';
import '../../../core/utils/file_type_utils.dart';
class FilesPage extends StatefulWidget {
const FilesPage({super.key});
@override
State<FilesPage> createState() => _FilesPageState();
}
class _FilesPageState extends State<FilesPage> {
bool _isFirstLoad = true;
FileModel? _infoFile;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final ScrollController _scrollController = ScrollController();
final ScrollController _breadcrumbController = ScrollController();
// FAB 状态
bool _isFabVisible = true;
bool _isFabExpanded = false;
Timer? _fabShowTimer;
// 桌面端拖拽状态
bool _isDraggingOver = false;
// 滑动手势追踪
Offset? _swipeStartPos;
DateTime? _swipeStartTime;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScrollForPagination);
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth >= 1000) {
fileManager.setViewType(FileViewType.grid);
} else {
fileManager.setViewType(FileViewType.list);
}
fileManager.restoreSortOption();
if (_isFirstLoad) {
fileManager.loadFiles();
_isFirstLoad = false;
}
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
downloadManager.initialize();
}
});
// 上传完成 → 自动刷新当前目录文件列表
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
if (!mounted) return;
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final normalizedCurrent = FileUtils.toCloudreveUri(
fileManager.currentPath,
);
if (targetPath == normalizedCurrent) {
final fileUri = targetPath.endsWith('/')
? '$targetPath$fileName'
: '$targetPath/$fileName';
fileManager.addFileByUri(fileUri);
}
};
}
@override
void dispose() {
_scrollController.removeListener(_onScrollForPagination);
_scrollController.dispose();
_breadcrumbController.dispose();
_fabShowTimer?.cancel();
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
UploadService.instance.onUploadCompleted = null;
super.dispose();
}
void _onScrollForPagination() {
if (!_scrollController.hasClients) return;
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
if (!fileManager.hasMore ||
fileManager.isLoadingMore ||
fileManager.isLoading) {
return;
}
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 320) {
fileManager.loadMoreFiles();
}
}
bool _handleKeyEvent(KeyEvent event) {
if (!mounted || event is! KeyDownEvent) return false;
// Ctrl+F / Cmd+F → 打开搜索
if (event.logicalKey == LogicalKeyboardKey.keyF &&
(HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed)) {
if (ModalRoute.of(context)?.isCurrent == false) return false;
final nav = Provider.of<NavigationProvider>(context, listen: false);
if (nav.currentIndex == 1 && !SearchDialog.isShowing) {
SearchDialog.show(context);
return true;
}
}
return false;
}
void _onPointerDown(PointerDownEvent event) {
_swipeStartPos = event.position;
_swipeStartTime = DateTime.now();
}
void _onPointerUp(PointerUpEvent event) {
if (_swipeStartPos == null || _swipeStartTime == null) return;
final dx = event.position.dx - _swipeStartPos!.dx;
final dy = event.position.dy - _swipeStartPos!.dy;
final duration = DateTime.now().difference(_swipeStartTime!);
_swipeStartPos = null;
_swipeStartTime = null;
// 从右往左快速滑动 → 返回上一级
if (dx < -150 &&
dx.abs() > dy.abs() * 1.5 &&
duration.inMilliseconds < 500) {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
if (fileManager.currentPath != '/') {
fileManager.goBack();
}
}
}
void _showFileInfo(FileModel file) {
setState(() => _infoFile = file);
// 等待下一帧 rebuild 完成,endDrawer 从 null 变为非 null 后再打开
WidgetsBinding.instance.addPostFrameCallback((_) {
_scaffoldKey.currentState?.openEndDrawer();
});
}
void _showSelectionMore(FileModel file, FileManagerProvider fileManager) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('重命名'),
onTap: () {
Navigator.of(sheetContext).pop();
FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('查看详情'),
onTap: () {
Navigator.of(sheetContext).pop();
_showFileInfo(file);
},
),
],
),
),
);
}
// ---- FAB 显隐控制 ----
void _hideFab() {
_fabShowTimer?.cancel();
if (_isFabVisible) {
setState(() {
_isFabVisible = false;
_isFabExpanded = false;
});
}
}
void _scheduleShowFab() {
_fabShowTimer?.cancel();
_fabShowTimer = Timer(const Duration(seconds: 1), () {
if (mounted && !_isFabVisible) {
setState(() => _isFabVisible = true);
}
});
}
bool _onScrollNotification(ScrollNotification notification) {
if (notification is ScrollStartNotification ||
notification is ScrollUpdateNotification) {
_hideFab();
} else if (notification is ScrollEndNotification) {
_scheduleShowFab();
}
return false;
}
void _toggleFabExpanded() {
setState(() => _isFabExpanded = !_isFabExpanded);
}
void _onFabSubAction(VoidCallback action) {
setState(() => _isFabExpanded = false);
action();
}
// ---- 构建方法 ----
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
return Listener(
onPointerDown: _onPointerDown,
onPointerUp: _onPointerUp,
child: Scaffold(
key: _scaffoldKey,
appBar: _buildAppBar(context),
body: _buildBody(context),
bottomNavigationBar: _buildBottomBar(context),
endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null,
floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context),
),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000;
return AppBar(
title: Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
if (isDesktop) {
if (fileManager.currentPath == '/') {
return const Text('文件');
}
final segments = fileManager.currentPath
.split('/')
.where((s) => s.isNotEmpty)
.toList();
return Text(
segments.isNotEmpty ? _decodePathSegment(segments.last) : '文件',
);
}
return _buildMobileBreadcrumb(context, fileManager);
},
),
actions: isDesktop ? _buildDesktopActions() : _buildMobileActions(),
);
}
/// 循环解码路径段,处理多重 URL 编码(如 %25E4%25B8%25AD → 中文)
String _decodePathSegment(String segment) {
var decoded = segment;
for (var i = 0; i < 5; i++) {
try {
final next = Uri.decodeComponent(decoded);
if (next == decoded) break;
decoded = next;
} catch (_) {
break;
}
}
return decoded;
}
Widget _buildMobileBreadcrumb(
BuildContext context,
FileManagerProvider fileManager,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final pathParts = fileManager.currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
// 路径变化后自动滚动到末尾
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_breadcrumbController.hasClients) {
_breadcrumbController.animateTo(
_breadcrumbController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
return SizedBox(
height: 40,
child: ListView(
controller: _breadcrumbController,
scrollDirection: Axis.horizontal,
children: [
_buildBreadcrumbChip(
context,
label: '文件',
icon: LucideIcons.home,
color: colorScheme.primary,
onTap: () => fileManager.currentPath != '/'
? fileManager.enterFolder('/')
: null,
),
for (int i = 0; i < pathParts.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
LucideIcons.chevronRight,
size: 14,
color: theme.hintColor.withValues(alpha: 0.5),
),
),
_buildBreadcrumbChip(
context,
label: _decodePathSegment(pathParts[i]),
icon: null,
color: colorScheme.primary,
isLast: i == pathParts.length - 1,
onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
if (targetPath != fileManager.currentPath) {
fileManager.enterFolder(targetPath);
}
},
),
],
],
),
);
}
Widget _buildBreadcrumbChip(
BuildContext context, {
required String label,
required IconData? icon,
required Color color,
bool isLast = false,
VoidCallback? onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: isLast
? color.withValues(alpha: 0.15)
: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: color),
const SizedBox(width: 3),
],
Text(
label,
style: TextStyle(
color: color,
fontWeight: isLast ? FontWeight.w600 : FontWeight.w500,
fontSize: 12,
),
),
],
),
),
);
}
List<Widget> _buildDesktopActions() {
return [
IconButton(
icon: const Icon(LucideIcons.search),
onPressed: () => SearchDialog.show(context),
tooltip: '搜索',
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return IconButton(
icon: Icon(
fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh,
),
onPressed: () => fileManager.refreshFiles(),
tooltip: '刷新',
);
},
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return _buildSortMenu(fileManager);
},
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
final icon = fileManager.viewType == FileViewType.list
? Icons.grid_view
: Icons.view_list;
return IconButton(
icon: Icon(icon),
onPressed: () {
fileManager.setViewType(
fileManager.viewType == FileViewType.list
? FileViewType.grid
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
);
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
FileOperationDialogs.showCreateDialog(context, fileManager);
},
tooltip: '新建',
),
IconButton(
icon: const Icon(Icons.cloud_upload),
onPressed: () => showUploadDialog(context),
tooltip: '上传',
),
IconButton(
icon: const Icon(Icons.cloud_download),
onPressed: () =>
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
tooltip: '下载',
),
];
}
List<Widget> _buildMobileActions() {
return [
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return _buildSortMenu(fileManager);
},
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
final icon = fileManager.viewType == FileViewType.list
? Icons.grid_view
: Icons.view_list;
return IconButton(
icon: Icon(icon),
onPressed: () {
fileManager.setViewType(
fileManager.viewType == FileViewType.list
? FileViewType.grid
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
);
},
),
];
}
Widget _buildSortMenu(FileManagerProvider fileManager) {
final allOptions = [
for (final field in SortField.values)
for (final dir in SortDirection.values) SortOption(field, dir),
];
return PopupMenuButton<SortOption>(
icon: const Icon(LucideIcons.arrowUpDown),
tooltip: '排序',
position: PopupMenuPosition.under,
onSelected: (option) => fileManager.setSortOption(option),
itemBuilder: (context) => allOptions.map((option) {
final isSelected = fileManager.sortOption == option;
return PopupMenuItem<SortOption>(
value: option,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
Icons.check,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
)
else
const SizedBox(width: 24),
Text(option.menuLabel),
],
),
);
}).toList(),
);
}
// ---- SpeedDial FAB ----
Widget _buildSpeedDialFAB(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return AnimatedSlide(
offset: _isFabVisible ? Offset.zero : const Offset(0, 2),
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: AnimatedOpacity(
opacity: _isFabVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildFabSubItem(
context: context,
index: 0,
icon: LucideIcons.search,
label: '搜索',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => SearchDialog.show(context)),
),
_buildFabSubItem(
context: context,
index: 1,
icon: LucideIcons.upload,
label: '上传',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => showUploadDialog(context)),
),
_buildFabSubItem(
context: context,
index: 2,
icon: LucideIcons.folderPlus,
label: '新建文件夹',
isDark: isDark,
colorScheme: colorScheme,
onTap: () {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
_onFabSubAction(
() => FileOperationDialogs.showCreateDialog(
context,
fileManager,
),
);
},
),
_buildFabSubItem(
context: context,
index: 3,
icon: LucideIcons.download,
label: '离线下载',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(
() =>
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
),
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, _) {
final isListView = fileManager.viewType == FileViewType.list;
return _buildFabSubItem(
context: context,
index: 4,
icon: isListView ? LucideIcons.layoutGrid : LucideIcons.list,
label: isListView ? '网格视图' : '列表视图',
isDark: isDark,
colorScheme: colorScheme,
onTap: () {
_onFabSubAction(() {
fileManager.setViewType(
isListView ? FileViewType.grid : FileViewType.list,
);
});
},
);
},
),
// 主按钮:与子按钮同风格同尺寸
Padding(
padding: const EdgeInsets.only(bottom: 4, right: 4),
child: AnimatedScale(
scale: _isFabExpanded ? 1.0 : 1.08,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _buildFabButton(
isDark: isDark,
colorScheme: colorScheme,
onTap: _toggleFabExpanded,
child: AnimatedRotation(
turns: _isFabExpanded ? 0.125 : 0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: Icon(
LucideIcons.plus,
color: colorScheme.primary,
size: 22,
),
),
),
),
),
],
),
),
);
}
Widget _buildFabSubItem({
required BuildContext context,
required int index,
required IconData icon,
required String label,
required bool isDark,
required ColorScheme colorScheme,
required VoidCallback onTap,
}) {
final staggerDelay = Duration(milliseconds: 50 * index);
return AnimatedSlide(
offset: _isFabExpanded ? Offset.zero : const Offset(0, 1.2),
duration: const Duration(milliseconds: 250) + staggerDelay,
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: _isFabExpanded ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200) + staggerDelay,
curve: Curves.easeOut,
child: AnimatedScale(
scale: _isFabExpanded ? 1.0 : 0.4,
duration: const Duration(milliseconds: 250) + staggerDelay,
curve: Curves.easeOutCubic,
child: Padding(
padding: const EdgeInsets.only(bottom: 14, right: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.white.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.white.withValues(alpha: 0.4),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.grey.shade800,
),
),
),
),
),
const SizedBox(width: 12),
_buildFabButton(
isDark: isDark,
colorScheme: colorScheme,
onTap: onTap,
child: Icon(icon, size: 20, color: colorScheme.primary),
),
],
),
),
),
),
);
}
/// 统一的毛玻璃圆形按钮
Widget _buildFabButton({
required bool isDark,
required ColorScheme colorScheme,
required VoidCallback onTap,
required Widget child,
}) {
const size = 44.0;
const radius = 22.0;
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: colorScheme.primary.withValues(alpha: isDark ? 0.25 : 0.2),
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(radius),
onTap: onTap,
child: Center(child: child),
),
),
),
),
);
}
// ---- Body ----
Widget _buildBody(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
final child = _buildFileList(context);
if (!isDesktop ||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
return child;
}
return DropTarget(
onDragEntered: (_) => setState(() => _isDraggingOver = true),
onDragExited: (_) => setState(() => _isDraggingOver = false),
onDragDone: (details) {
setState(() => _isDraggingOver = false);
_handleDroppedFiles(details.files);
},
child: Stack(
children: [
child,
if (_isDraggingOver)
IgnorePointer(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(8),
color: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.85),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.upload,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
Text(
'释放文件以上传到当前目录',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
],
),
);
}
void _handleDroppedFiles(List<XFile> droppedFiles) {
final files = <File>[];
for (final xFile in droppedFiles) {
final path = xFile.path;
if (path.isNotEmpty) {
files.add(File(path));
}
}
if (files.isEmpty) return;
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
uploadManager.markShouldShowDialog();
uploadManager.startUpload(files, fileManager.currentPath);
ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
}
Widget _buildFileList(BuildContext context) {
return Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
if (fileManager.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (fileManager.errorMessage != null) {
return _buildErrorView(context, fileManager);
}
if (fileManager.files.isEmpty) {
return EmptyFolderView(currentPath: fileManager.currentPath);
}
if (fileManager.viewType == FileViewType.list) {
return _buildListView(context, fileManager);
}
return _buildGridView(context, fileManager);
},
);
}
Widget _buildErrorView(
BuildContext context,
FileManagerProvider fileManager,
) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
fileManager.errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).hintColor),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => fileManager.loadFiles(),
child: const Text('重试'),
),
],
),
);
}
Future<void> _onRefresh(FileManagerProvider fileManager) async {
final result = await fileManager.refreshFiles();
if (!mounted) return;
if (fileManager.errorMessage != null) {
ToastHelper.error(fileManager.errorMessage!);
return;
}
if (result.isUnchanged) {
ToastHelper.info('列表已是最新');
} else {
final parts = <String>[];
if (result.added > 0) parts.add('新增 ${result.added}');
if (result.removed > 0) parts.add('移除 ${result.removed}');
if (result.updated > 0) parts.add('更新 ${result.updated}');
ToastHelper.success('已刷新:${parts.join('')}');
}
}
Widget _buildListView(BuildContext context, FileManagerProvider fileManager) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
final showCheckbox = fileManager.hasSelection;
final itemCount =
fileManager.files.length +
(fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0);
return Column(
children: [
if (isDesktop)
FileListHeader(
showCheckbox: showCheckbox,
currentSort: fileManager.sortOption,
onSort: (option) => fileManager.setSortOption(option),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => _onRefresh(fileManager),
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: ListView.builder(
controller: _scrollController,
key: PageStorageKey('files_list_${fileManager.currentPath}'),
cacheExtent: 900,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: itemCount,
itemBuilder: (context, index) {
if (index >= fileManager.files.length) {
return _buildLoadMoreIndicator(context, fileManager);
}
final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(
file.path,
);
return FileListItem(
key: ValueKey('file_${file.id}'),
file: file,
isSelected: isSelected,
isHighlighted: file.path == fileManager.highlightPath,
showCheckbox: showCheckbox,
index: index,
isDesktop: isDesktop,
onTap: () {
_hideFab();
_scheduleShowFab();
if (showCheckbox) {
fileManager.toggleSelection(file.path);
} else if (file.isFolder) {
fileManager.enterFolder(file.relativePath);
} else {
_openFile(context, file);
}
},
onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder
? () => _downloadFile(context, fileManager, file)
: null,
onOpenInBrowser: !file.isFolder
? () => _openInBrowser(context, file)
: null,
onOpenInCloudreveApp: !file.isFolder
? () => _openInCloudreveApp(context, file)
: null,
onRename: () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
),
onMove: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
false,
),
onCopy: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
true,
),
onShare: () =>
FileOperationDialogs.showShareDialog(context, file),
onDelete: () =>
FileOperationDialogs.showDeleteSingleConfirmation(
context,
fileManager,
file,
),
onInfo: () => _showFileInfo(file),
);
},
),
),
),
),
],
);
}
Widget _buildGridView(BuildContext context, FileManagerProvider fileManager) {
final screenWidth = MediaQuery.sizeOf(context).width;
final padding = 16.0;
final spacing = 16.0;
final availableWidth = screenWidth - padding * 2;
int crossAxisCount;
if (screenWidth < 400) {
crossAxisCount = 2;
} else if (screenWidth < 600) {
crossAxisCount = 3;
} else if (screenWidth < 900) {
crossAxisCount = 4;
} else {
crossAxisCount = 5;
}
final itemWidth =
(availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final childAspectRatio = itemWidth / 160;
final showCheckbox = fileManager.hasSelection;
final itemCount =
fileManager.files.length +
(fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0);
return RefreshIndicator(
onRefresh: () => _onRefresh(fileManager),
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: GridView.builder(
controller: _scrollController,
key: PageStorageKey('files_grid_${fileManager.currentPath}'),
cacheExtent: 1100,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: spacing / 2,
crossAxisSpacing: spacing / 2,
childAspectRatio: childAspectRatio,
),
itemCount: itemCount,
itemBuilder: (context, index) {
if (index >= fileManager.files.length) {
return _buildGridLoadMoreIndicator(context, fileManager);
}
final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(file.path);
return FileGridItem(
key: ValueKey('file_grid_${file.id}'),
file: file,
isSelected: isSelected,
isHighlighted: file.path == fileManager.highlightPath,
showCheckbox: showCheckbox,
contextHint: fileManager.contextHint,
onTap: () {
_hideFab();
_scheduleShowFab();
if (showCheckbox) {
fileManager.toggleSelection(file.path);
} else if (file.isFolder) {
fileManager.enterFolder(file.relativePath);
} else {
_openFile(context, file);
}
},
onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder
? () => _downloadFile(context, fileManager, file)
: null,
onOpenInBrowser: !file.isFolder
? () => _openInBrowser(context, file)
: null,
onOpenInCloudreveApp: !file.isFolder
? () => _openInCloudreveApp(context, file)
: null,
onRename: () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
),
onMove: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
false,
),
onCopy: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
true,
),
onShare: () =>
FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(
context,
fileManager,
file,
),
onInfo: () => _showFileInfo(file),
);
},
),
),
);
}
Widget _buildLoadMoreIndicator(
BuildContext context,
FileManagerProvider fileManager,
) {
if (fileManager.isLoadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: OutlinedButton.icon(
onPressed: () => fileManager.loadMoreFiles(),
icon: const Icon(LucideIcons.chevronsDown, size: 16),
label: const Text('加载更多'),
),
),
);
}
Widget _buildGridLoadMoreIndicator(
BuildContext context,
FileManagerProvider fileManager,
) {
if (fileManager.isLoadingMore) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: OutlinedButton.icon(
onPressed: () => fileManager.loadMoreFiles(),
icon: const Icon(LucideIcons.chevronsDown, size: 16),
label: const Text('加载更多'),
),
),
);
}
Widget _buildBottomBar(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000;
return Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
if (fileManager.hasSelection) {
return SelectionToolbar(
selectionCount: fileManager.selectedFiles.length,
totalCount: fileManager.files.length,
onCancel: () => fileManager.clearSelection(),
onSelectAll: () => fileManager.selectAll(),
onMore: fileManager.selectedFiles.length == 1
? () => _showSelectionMore(
fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first,
),
fileManager,
)
: null,
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
true,
),
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
context,
fileManager,
fileManager.selectedFiles,
),
);
}
if (!isDesktop) return const SizedBox.shrink();
return FileBreadcrumb(
currentPath: fileManager.currentPath,
onPathTap: (path) => fileManager.enterFolder(path),
);
},
);
}
void _openFile(BuildContext context, FileModel file) {
if (FileTypeUtils.isImage(file.name)) {
Navigator.of(context).pushNamed(RouteNames.imagePreview, arguments: file);
} else if (FileTypeUtils.isPdf(file.name)) {
Navigator.of(context).pushNamed(RouteNames.pdfPreview, arguments: file);
} else if (FileTypeUtils.isVideo(file.name)) {
Navigator.of(context).pushNamed(RouteNames.videoPreview, arguments: file);
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
}
}
Future<void> _downloadFile(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
) async {
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
final task = await downloadManager.addDownloadTask(
fileName: file.name,
fileUri: file.relativePath,
fileSize: file.size,
);
if (task != null) {
if (context.mounted) {
ToastHelper.info('文件已在下载列表中');
}
return;
}
if (context.mounted) {
ToastHelper.info('开始下载,查看任务页');
}
}
Future<void> _openInBrowser(BuildContext context, FileModel file) async {
try {
final response = await FileService().getDownloadUrls(
uris: [file.relativePath],
download: true,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.platformDefault);
} else {
if (context.mounted) {
ToastHelper.error('无法打开链接: $uri');
}
}
}
} catch (e) {
if (context.mounted) {
ToastHelper.failure('获取下载链接失败: $e');
}
}
}
void _openInCloudreveApp(BuildContext context, FileModel file) {
Navigator.of(
context,
).pushNamed(RouteNames.cloudreveFileApp, arguments: {'file': file});
}
}