主要合入内容:

- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、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。
This commit is contained in:
2026-06-04 07:11:43 +08:00
parent 5ee6ba1e28
commit b02daf1448
56 changed files with 7589 additions and 1200 deletions
@@ -4,10 +4,13 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../core/utils/date_utils.dart' as date_utils;
import '../../../core/constants/sort_options.dart';
import '../../../core/constants/storage_keys.dart';
import '../../../core/utils/file_type_utils.dart';
import '../../../data/models/file_model.dart';
import '../../../router/app_router.dart';
import '../../../services/file_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/file_manager_provider.dart';
import '../../widgets/file_info_dialog.dart';
import '../../widgets/file_operation_dialogs.dart';
@@ -52,10 +55,7 @@ class CategoryFilesPageArgs {
class CategoryFilesPage extends StatefulWidget {
final CategoryFilesPageArgs args;
const CategoryFilesPage({
super.key,
required this.args,
});
const CategoryFilesPage({super.key, required this.args});
@override
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
@@ -73,6 +73,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
bool _isLoading = true;
bool _isLoadingMore = false;
String? _errorMessage;
SortOption _sortOption = SortOption.default_;
bool get _hasSelection => _selectedFilePaths.isNotEmpty;
@@ -83,6 +84,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
@override
void initState() {
super.initState();
_restoreSortOption();
_loadFiles(refresh: true);
_scrollController.addListener(_onScroll);
}
@@ -133,11 +135,14 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
final response = await _fileService.listFilesByCategory(
category: widget.args.category,
pageSize: 100,
orderBy: _sortOption.field.apiKey,
orderDirection: _sortOption.direction.apiKey,
nextPageToken: refresh ? null : _nextPageToken,
);
final filesData = response['files'] as List<dynamic>? ?? const [];
final pagination = response['pagination'] as Map<String, dynamic>? ?? const {};
final pagination =
response['pagination'] as Map<String, dynamic>? ?? const {};
final newFiles = filesData
.map((item) => FileModel.fromJson(item as Map<String, dynamic>))
.where((file) => file.isFile)
@@ -152,7 +157,9 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
..addAll(newFiles);
} else {
final existingIds = _files.map((e) => e.id).toSet();
_files.addAll(newFiles.where((file) => !existingIds.contains(file.id)));
_files.addAll(
newFiles.where((file) => !existingIds.contains(file.id)),
);
}
_nextPageToken = pagination['next_token'] as String?;
@@ -170,8 +177,65 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
}
}
Widget _buildSortMenu() {
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: _setSortOption,
itemBuilder: (context) => allOptions.map((option) {
final isSelected = _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(),
);
}
Future<void> _refresh() => _loadFiles(refresh: true);
Future<void> _restoreSortOption() async {
final key = await StorageService.instance.getString(
StorageKeys.fileSortOption,
);
final option = SortOption.fromKey(key);
if (option != _sortOption) {
setState(() => _sortOption = option);
}
}
Future<void> _setSortOption(SortOption option) async {
if (_sortOption == option) return;
setState(() => _sortOption = option);
await StorageService.instance.setString(
StorageKeys.fileSortOption,
option.toKey(),
);
await _loadFiles(refresh: true);
}
void _toggleSelection(FileModel file) {
HapticFeedback.selectionClick();
setState(() {
@@ -207,10 +271,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
},
child: Scaffold(
appBar: _buildAppBar(context),
body: RefreshIndicator(
onRefresh: _refresh,
child: _buildBody(context),
),
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
bottomNavigationBar: _buildSelectionBottomBar(context),
),
);
@@ -230,10 +291,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
centerTitle: true,
title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
actions: [
TextButton(
onPressed: _selectAllVisible,
child: const Text('全选'),
),
TextButton(onPressed: _selectAllVisible, child: const Text('全选')),
],
);
}
@@ -241,6 +299,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
return AppBar(
title: Text(args.title),
actions: [
_buildSortMenu(),
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: '刷新',
@@ -265,10 +324,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.bottomCenter,
children: <Widget>[
...previousChildren,
?currentChild,
],
children: <Widget>[...previousChildren, ?currentChild],
);
},
transitionBuilder: (child, animation) {
@@ -298,17 +354,17 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
? null
: () => _showSelectionMore(context, singleSelected),
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
false,
),
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
true,
),
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
true,
),
onDelete: () => _deleteSelectedFiles(context, selected),
)
: const SizedBox.shrink(
@@ -359,11 +415,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
Icon(
widget.args.icon,
size: 52,
color: widget.args.color,
),
Icon(widget.args.icon, size: 52, color: widget.args.color),
const SizedBox(height: 12),
Center(
child: Text(
@@ -383,7 +435,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
final horizontalPadding = width >= 720 ? 16.0 : 10.0;
final columnWidth =
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
columnCount;
columnCount;
final columns = List.generate(columnCount, (_) => <FileModel>[]);
final heights = List.generate(columnCount, (_) => 0.0);
@@ -391,7 +443,8 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
for (final file in _files) {
final targetIndex = _indexOfMin(heights);
columns[targetIndex].add(file);
heights[targetIndex] += _estimatedTileHeight(file, columnWidth) + spacing;
heights[targetIndex] +=
_estimatedTileHeight(file, columnWidth) + spacing;
}
return ListView(
@@ -417,12 +470,16 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
Padding(
padding: EdgeInsets.only(bottom: spacing),
child: _CategoryFileTile(
key: ValueKey('category-file-${file.id.isNotEmpty ? file.id : file.path}'),
key: ValueKey(
'category-file-${file.id.isNotEmpty ? file.id : file.path}',
),
file: file,
contextHint: _contextHint,
category: widget.args.category,
accentColor: widget.args.color,
isSelected: _selectedFilePaths.contains(file.path),
isSelected: _selectedFilePaths.contains(
file.path,
),
selectionMode: _hasSelection,
onTap: () {
if (_hasSelection) {
@@ -536,11 +593,17 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
} 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);
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
}
}
@@ -760,9 +823,7 @@ class _CategoryFileTile extends StatelessWidget {
),
),
if (category == 'video')
const Center(
child: _PlayOverlay(),
),
const Center(child: _PlayOverlay()),
],
),
),
@@ -807,8 +868,9 @@ class _CategoryFileTile extends StatelessWidget {
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary
.withValues(alpha: 0.12),
color: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
blurRadius: 10,
spreadRadius: 0.5,
),
@@ -877,10 +939,7 @@ class _SelectionCircle extends StatelessWidget {
final bool selected;
final VoidCallback? onTap;
const _SelectionCircle({
required this.selected,
this.onTap,
});
const _SelectionCircle({required this.selected, this.onTap});
@override
Widget build(BuildContext context) {
@@ -915,11 +974,7 @@ class _SelectionCircle extends StatelessWidget {
],
),
child: selected
? const Icon(
LucideIcons.check,
color: Colors.white,
size: 16,
)
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
: null,
),
),
@@ -948,10 +1003,7 @@ class _TypeBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(9),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 7,
vertical: 4,
),
padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -986,11 +1038,7 @@ class _PlayOverlay extends StatelessWidget {
),
child: const Padding(
padding: EdgeInsets.all(10),
child: Icon(
LucideIcons.play,
color: Colors.white,
size: 22,
),
child: Icon(LucideIcons.play, color: Colors.white, size: 22),
),
);
}
+436 -133
View File
@@ -4,10 +4,12 @@ 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';
@@ -41,6 +43,8 @@ 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;
@@ -50,24 +54,37 @@ class _FilesPageState extends State<FilesPage> {
// 桌面端拖拽状态
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 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);
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
downloadManager.initialize();
}
});
@@ -75,8 +92,13 @@ class _FilesPageState extends State<FilesPage> {
// 上传完成 → 自动刷新当前目录文件列表
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
if (!mounted) return;
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final normalizedCurrent = FileUtils.toCloudreveUri(
fileManager.currentPath,
);
if (targetPath == normalizedCurrent) {
final fileUri = targetPath.endsWith('/')
? '$targetPath$fileName'
@@ -88,11 +110,74 @@ class _FilesPageState extends State<FilesPage> {
@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 后再打开
@@ -101,10 +186,7 @@ class _FilesPageState extends State<FilesPage> {
});
}
void _showSelectionMore(
FileModel file,
FileManagerProvider fileManager,
) {
void _showSelectionMore(FileModel file, FileManagerProvider fileManager) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
@@ -116,7 +198,11 @@ class _FilesPageState extends State<FilesPage> {
title: const Text('重命名'),
onTap: () {
Navigator.of(sheetContext).pop();
FileOperationDialogs.showRenameDialog(context, fileManager, file);
FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
);
},
),
ListTile(
@@ -179,13 +265,17 @@ class _FilesPageState extends State<FilesPage> {
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
return Scaffold(
key: _scaffoldKey,
appBar: _buildAppBar(context),
body: _buildBody(context),
bottomNavigationBar: _buildBottomBar(context),
endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null,
floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context),
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),
),
);
}
@@ -200,7 +290,13 @@ class _FilesPageState extends State<FilesPage> {
if (fileManager.currentPath == '/') {
return const Text('文件');
}
return _buildDesktopBreadcrumb(context, fileManager);
final segments = fileManager.currentPath
.split('/')
.where((s) => s.isNotEmpty)
.toList();
return Text(
segments.isNotEmpty ? _decodePathSegment(segments.last) : '文件',
);
}
return _buildMobileBreadcrumb(context, fileManager);
},
@@ -224,60 +320,30 @@ class _FilesPageState extends State<FilesPage> {
return decoded;
}
Widget _buildDesktopBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
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);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Icon(LucideIcons.home, size: 18, color: colorScheme.primary),
),
),
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)),
),
InkWell(
onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(
_decodePathSegment(pathParts[i]),
style: TextStyle(
color: i == pathParts.length - 1 ? colorScheme.onSurface : colorScheme.primary,
fontWeight: i == pathParts.length - 1 ? FontWeight.w600 : FontWeight.normal,
),
),
),
),
],
],
);
}
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(
key: const PageStorageKey('mobile_breadcrumb'),
controller: _breadcrumbController,
scrollDirection: Axis.horizontal,
children: [
_buildBreadcrumbChip(
@@ -285,12 +351,18 @@ class _FilesPageState extends State<FilesPage> {
label: '文件',
icon: LucideIcons.home,
color: colorScheme.primary,
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
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)),
child: Icon(
LucideIcons.chevronRight,
size: 14,
color: theme.hintColor.withValues(alpha: 0.5),
),
),
_buildBreadcrumbChip(
context,
@@ -300,7 +372,9 @@ class _FilesPageState extends State<FilesPage> {
isLast: i == pathParts.length - 1,
onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
if (targetPath != fileManager.currentPath) {
fileManager.enterFolder(targetPath);
}
},
),
],
@@ -324,7 +398,9 @@ class _FilesPageState extends State<FilesPage> {
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06),
color: isLast
? color.withValues(alpha: 0.15)
: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
),
child: Row(
@@ -358,12 +434,19 @@ class _FilesPageState extends State<FilesPage> {
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return IconButton(
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh),
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
@@ -378,14 +461,19 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
);
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
FileOperationDialogs.showCreateDialog(context, fileManager);
},
tooltip: '新建',
@@ -397,7 +485,8 @@ class _FilesPageState extends State<FilesPage> {
),
IconButton(
icon: const Icon(Icons.cloud_download),
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
onPressed: () =>
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
tooltip: '下载',
),
];
@@ -405,6 +494,11 @@ class _FilesPageState extends State<FilesPage> {
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
@@ -419,13 +513,52 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list,
);
},
tooltip: fileManager.viewType == 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) {
@@ -471,8 +604,16 @@ class _FilesPageState extends State<FilesPage> {
isDark: isDark,
colorScheme: colorScheme,
onTap: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager));
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
_onFabSubAction(
() => FileOperationDialogs.showCreateDialog(
context,
fileManager,
),
);
},
),
_buildFabSubItem(
@@ -482,7 +623,10 @@ class _FilesPageState extends State<FilesPage> {
label: '离线下载',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)),
onTap: () => _onFabSubAction(
() =>
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
),
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, _) {
@@ -568,7 +712,10 @@ class _FilesPageState extends State<FilesPage> {
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.12)
@@ -649,7 +796,8 @@ class _FilesPageState extends State<FilesPage> {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
final child = _buildFileList(context);
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
if (!isDesktop ||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
return child;
}
@@ -673,13 +821,19 @@ class _FilesPageState extends State<FilesPage> {
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
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),
Icon(
LucideIcons.upload,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
Text(
'释放文件以上传到当前目录',
@@ -709,8 +863,14 @@ class _FilesPageState extends State<FilesPage> {
}
if (files.isEmpty) return;
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
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} 个文件到上传队列');
@@ -740,12 +900,19 @@ class _FilesPageState extends State<FilesPage> {
);
}
Widget _buildErrorView(BuildContext context, FileManagerProvider 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),
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
fileManager.errorMessage!,
@@ -783,23 +950,38 @@ class _FilesPageState extends State<FilesPage> {
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),
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: fileManager.files.length,
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);
final isSelected = fileManager.selectedFiles.contains(
file.path,
);
return FileListItem(
key: ValueKey('file_${file.id}'),
@@ -821,14 +1003,40 @@ class _FilesPageState extends State<FilesPage> {
}
},
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),
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),
);
},
@@ -857,15 +1065,20 @@ class _FilesPageState extends State<FilesPage> {
crossAxisCount = 5;
}
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
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,
@@ -876,8 +1089,11 @@ class _FilesPageState extends State<FilesPage> {
crossAxisSpacing: spacing / 2,
childAspectRatio: childAspectRatio,
),
itemCount: fileManager.files.length,
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);
@@ -900,14 +1116,39 @@ class _FilesPageState extends State<FilesPage> {
}
},
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),
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),
);
},
@@ -916,6 +1157,62 @@ class _FilesPageState extends State<FilesPage> {
);
}
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;
@@ -930,29 +1227,29 @@ class _FilesPageState extends State<FilesPage> {
onSelectAll: () => fileManager.selectAll(),
onMore: fileManager.selectedFiles.length == 1
? () => _showSelectionMore(
fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first,
),
fileManager,
)
fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first,
),
fileManager,
)
: null,
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
false,
),
context,
fileManager,
fileManager.selectedFiles,
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
true,
),
context,
fileManager,
fileManager.selectedFiles,
true,
),
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
context,
fileManager,
fileManager.selectedFiles,
),
context,
fileManager,
fileManager.selectedFiles,
),
);
}
@@ -976,11 +1273,17 @@ class _FilesPageState extends State<FilesPage> {
} 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);
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
}
}
@@ -989,7 +1292,10 @@ class _FilesPageState extends State<FilesPage> {
FileManagerProvider fileManager,
FileModel file,
) async {
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
final task = await downloadManager.addDownloadTask(
fileName: file.name,
fileUri: file.relativePath,
@@ -1037,11 +1343,8 @@ class _FilesPageState extends State<FilesPage> {
}
void _openInCloudreveApp(BuildContext context, FileModel file) {
Navigator.of(context).pushNamed(
RouteNames.cloudreveFileApp,
arguments: {
'file': file,
},
);
Navigator.of(
context,
).pushNamed(RouteNames.cloudreveFileApp, arguments: {'file': file});
}
}