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。
525 lines
14 KiB
Dart
525 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import '../../data/models/file_model.dart';
|
|
import '../../services/file_service.dart';
|
|
import '../../services/storage_service.dart';
|
|
import '../../services/thumbnail_service.dart';
|
|
import '../../core/constants/sort_options.dart';
|
|
import '../../core/constants/storage_keys.dart';
|
|
import '../../core/utils/app_logger.dart';
|
|
import '../../core/utils/file_utils.dart';
|
|
|
|
/// 文件视图类型
|
|
enum FileViewType { list, grid, gallery }
|
|
|
|
/// 刷新结果
|
|
class RefreshResult {
|
|
final int added;
|
|
final int removed;
|
|
final int updated;
|
|
const RefreshResult({
|
|
required this.added,
|
|
required this.removed,
|
|
required this.updated,
|
|
});
|
|
bool get isUnchanged => added == 0 && removed == 0 && updated == 0;
|
|
}
|
|
|
|
/// 文件管理Provider
|
|
class FileManagerProvider extends ChangeNotifier {
|
|
String _currentPath = '/';
|
|
List<FileModel> _files = [];
|
|
List<String> _selectedFiles = [];
|
|
FileViewType _viewType = FileViewType.list;
|
|
SortOption _sortOption = SortOption.default_;
|
|
bool _isLoading = false;
|
|
bool _isLoadingMore = false;
|
|
bool _hasMore = true;
|
|
String? _nextPageToken;
|
|
String? _errorMessage;
|
|
String? _contextHint;
|
|
String? _highlightPath;
|
|
Timer? _highlightTimer;
|
|
|
|
String get currentPath => _currentPath;
|
|
List<FileModel> get files => _files;
|
|
List<String> get selectedFiles => _selectedFiles;
|
|
FileViewType get viewType => _viewType;
|
|
SortOption get sortOption => _sortOption;
|
|
bool get isLoading => _isLoading;
|
|
bool get isLoadingMore => _isLoadingMore;
|
|
bool get hasMore => _hasMore;
|
|
String? get nextPageToken => _nextPageToken;
|
|
String? get errorMessage => _errorMessage;
|
|
String? get contextHint => _contextHint;
|
|
bool get hasSelection => _selectedFiles.isNotEmpty;
|
|
String? get highlightPath => _highlightPath;
|
|
|
|
/// 加载文件列表
|
|
Future<void> loadFiles({
|
|
bool refresh = false,
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
if (refresh) {
|
|
_selectedFiles.clear();
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
_nextPageToken = null;
|
|
});
|
|
|
|
try {
|
|
final response = await FileService()
|
|
.listFiles(
|
|
uri: _currentPath,
|
|
pageSize: 50,
|
|
orderBy: _sortOption.field.apiKey,
|
|
orderDirection: _sortOption.direction.apiKey,
|
|
)
|
|
.timeout(timeout);
|
|
|
|
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
|
|
final pagination = response['pagination'] as Map<String, dynamic>? ?? {};
|
|
AppLogger.d("获取files列表: $filesData");
|
|
setState(() {
|
|
_files = filesData
|
|
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
|
|
.toList();
|
|
_nextPageToken = pagination['next_token'] as String?;
|
|
_hasMore = _nextPageToken != null;
|
|
_contextHint = response['context_hint'] as String?;
|
|
});
|
|
} on TimeoutException {
|
|
setState(() {
|
|
_errorMessage = '加载超时,请检查网络后重试';
|
|
_hasMore = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_errorMessage = e.toString();
|
|
_hasMore = false;
|
|
});
|
|
} finally {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 加载更多文件(分页)
|
|
Future<void> loadMoreFiles({
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
if (_isLoadingMore || _nextPageToken == null) return;
|
|
|
|
setState(() {
|
|
_isLoadingMore = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final response = await FileService()
|
|
.listFiles(
|
|
uri: _currentPath,
|
|
pageSize: 50,
|
|
orderBy: _sortOption.field.apiKey,
|
|
orderDirection: _sortOption.direction.apiKey,
|
|
nextPageToken: _nextPageToken,
|
|
)
|
|
.timeout(timeout);
|
|
|
|
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
|
|
final pagination = response['pagination'] as Map<String, dynamic>? ?? {};
|
|
final newFiles = filesData
|
|
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
setState(() {
|
|
final existingIds = _files.map((e) => e.id).toSet();
|
|
_files.addAll(newFiles.where((f) => !existingIds.contains(f.id)));
|
|
_nextPageToken = pagination['next_token'] as String?;
|
|
_hasMore = _nextPageToken != null;
|
|
});
|
|
} on TimeoutException {
|
|
setState(() {
|
|
_errorMessage = '加载更多超时,请重试';
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_errorMessage = e.toString();
|
|
});
|
|
} finally {
|
|
setState(() {
|
|
_isLoadingMore = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 进入文件夹
|
|
Future<void> enterFolder(String path) async {
|
|
_currentPath = path;
|
|
_selectedFiles.clear();
|
|
_highlightPath = null;
|
|
_highlightTimer?.cancel();
|
|
_nextPageToken = null;
|
|
ThumbnailService.instance.clearAll();
|
|
await loadFiles();
|
|
}
|
|
|
|
/// 返回上级
|
|
Future<void> goBack() async {
|
|
if (_currentPath == '/' || _currentPath.isEmpty) return;
|
|
|
|
final parts = _currentPath.split('/');
|
|
if (parts.length > 1) {
|
|
parts.removeLast();
|
|
_currentPath = parts.join('/');
|
|
} else {
|
|
_currentPath = '/';
|
|
}
|
|
_selectedFiles.clear();
|
|
_highlightPath = null;
|
|
_highlightTimer?.cancel();
|
|
_nextPageToken = null;
|
|
ThumbnailService.instance.clearAll();
|
|
notifyListeners();
|
|
await loadFiles();
|
|
}
|
|
|
|
/// 选择/取消选择文件
|
|
void toggleSelection(String path) {
|
|
if (_selectedFiles.contains(path)) {
|
|
_selectedFiles.remove(path);
|
|
} else {
|
|
_selectedFiles.add(path);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 选择所有
|
|
void selectAll() {
|
|
_selectedFiles = _files.map((f) => f.path).toList();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 清除选择
|
|
void clearSelection() {
|
|
_selectedFiles.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 切换视图类型
|
|
void setViewType(FileViewType type) {
|
|
_viewType = type;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 设置排序选项并重新加载
|
|
Future<void> setSortOption(SortOption option) async {
|
|
if (_sortOption == option) return;
|
|
_sortOption = option;
|
|
notifyListeners();
|
|
await StorageService.instance.setString(
|
|
StorageKeys.fileSortOption,
|
|
option.toKey(),
|
|
);
|
|
await loadFiles(refresh: true);
|
|
}
|
|
|
|
/// 从持久化恢复排序偏好
|
|
Future<void> restoreSortOption() async {
|
|
final key = await StorageService.instance.getString(
|
|
StorageKeys.fileSortOption,
|
|
);
|
|
final option = SortOption.fromKey(key);
|
|
if (option != _sortOption) {
|
|
_sortOption = option;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 设置错误信息
|
|
void setErrorMessage(String? message) {
|
|
_errorMessage = message;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 设置状态
|
|
void setState(VoidCallback fn) {
|
|
fn();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 删除选中的文件
|
|
Future<String?> deleteSelectedFiles() async {
|
|
if (_selectedFiles.isEmpty) return null;
|
|
|
|
try {
|
|
AppLogger.d("删除文件: ${_selectedFiles.join(', ')}");
|
|
await FileService().deleteFiles(uris: _selectedFiles);
|
|
|
|
setState(() {
|
|
_files.removeWhere((file) => _selectedFiles.contains(file.path));
|
|
});
|
|
|
|
clearSelection();
|
|
return null;
|
|
} catch (e) {
|
|
final error = e.toString();
|
|
setErrorMessage(error);
|
|
return error;
|
|
}
|
|
}
|
|
|
|
/// 创建文件夹
|
|
Future<String?> createFolder(String name) async {
|
|
try {
|
|
String uri;
|
|
if (_currentPath == '/' || _currentPath.isEmpty) {
|
|
uri = '/$name';
|
|
} else {
|
|
uri = '$_currentPath/$name';
|
|
}
|
|
|
|
final response = await FileService().createFile(
|
|
uri: uri,
|
|
type: 'folder',
|
|
errOnConflict: true,
|
|
);
|
|
|
|
final newFolder = FileModel.fromJson(response);
|
|
|
|
setState(() {
|
|
_files.insert(0, newFolder);
|
|
});
|
|
|
|
return null;
|
|
} catch (e) {
|
|
final error = e.toString();
|
|
setErrorMessage(error);
|
|
return error;
|
|
}
|
|
}
|
|
|
|
/// 删除单个文件(增量移除)
|
|
Future<String?> deleteFile(String path) async {
|
|
try {
|
|
await FileService().deleteFiles(uris: [path]);
|
|
setState(() {
|
|
_files.removeWhere((file) => file.path == path);
|
|
_selectedFiles.remove(path);
|
|
});
|
|
return null;
|
|
} catch (e) {
|
|
final error = e.toString();
|
|
setErrorMessage(error);
|
|
return error;
|
|
}
|
|
}
|
|
|
|
/// 移动文件(增量更新)
|
|
Future<String?> moveFiles(
|
|
List<String> uris,
|
|
String destination, {
|
|
bool copy = false,
|
|
}) async {
|
|
try {
|
|
await FileService().moveFiles(uris: uris, dst: destination, copy: copy);
|
|
clearSelection();
|
|
|
|
if (!copy) {
|
|
// 移动:文件离开当前目录,直接从列表移除
|
|
setState(() {
|
|
_files.removeWhere((file) => uris.contains(file.path));
|
|
});
|
|
} else {
|
|
// 复制:仅当目标是当前目录时需要刷新
|
|
final normalizedDst = FileUtils.toCloudreveUri(destination);
|
|
final normalizedCur = FileUtils.toCloudreveUri(_currentPath);
|
|
if (normalizedDst == normalizedCur) {
|
|
await loadFiles();
|
|
}
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
final error = e.toString();
|
|
setErrorMessage(error);
|
|
return error;
|
|
}
|
|
}
|
|
|
|
/// 重命名文件(原地更新,不刷新列表)
|
|
Future<String?> renameFile(String path, String newName) async {
|
|
try {
|
|
final response = await FileService().renameFile(
|
|
uri: path,
|
|
newName: newName,
|
|
);
|
|
if (response.isEmpty) {
|
|
await loadFiles();
|
|
return null;
|
|
}
|
|
final updatedFile = FileModel.fromJson(response);
|
|
final index = _files.indexWhere((f) => f.path == path);
|
|
if (index != -1) {
|
|
setState(() {
|
|
_files[index] = updatedFile;
|
|
});
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
final error = e.toString();
|
|
setErrorMessage(error);
|
|
return error;
|
|
}
|
|
}
|
|
|
|
/// 通过 URI 获取文件信息并添加到列表(用于上传完成后)
|
|
Future<void> addFileByUri(String fileUri) async {
|
|
try {
|
|
final response = await FileService().getFileInfo(uri: fileUri);
|
|
final newFile = FileModel.fromJson(response);
|
|
final exists = _files.any((f) => f.id == newFile.id);
|
|
if (!exists) {
|
|
setState(() {
|
|
_files.insert(0, newFile);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
AppLogger.d('获取上传文件信息失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 高亮指定文件路径(3 秒后自动清除)
|
|
void setHighlightPath(String? path) {
|
|
_highlightTimer?.cancel();
|
|
_highlightPath = path;
|
|
notifyListeners();
|
|
if (path != null) {
|
|
_highlightTimer = Timer(const Duration(seconds: 3), () {
|
|
_highlightPath = null;
|
|
notifyListeners();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 导航到指定文件夹并高亮目标文件
|
|
Future<void> navigateAndHighlight(String folderPath, String filePath) async {
|
|
_currentPath = folderPath;
|
|
_selectedFiles.clear();
|
|
_highlightPath = null;
|
|
_highlightTimer?.cancel();
|
|
await loadFiles();
|
|
setHighlightPath(filePath);
|
|
}
|
|
|
|
/// 清空文件列表
|
|
void clearFiles() {
|
|
setState(() {
|
|
_files = [];
|
|
_selectedFiles = [];
|
|
_currentPath = '/';
|
|
_errorMessage = null;
|
|
_nextPageToken = null;
|
|
_hasMore = true;
|
|
});
|
|
}
|
|
|
|
/// 智能刷新 - 只更新差异部分(仅刷新首页)
|
|
Future<RefreshResult> refreshFiles({
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final response = await FileService()
|
|
.listFiles(
|
|
uri: _currentPath,
|
|
pageSize: 50,
|
|
orderBy: _sortOption.field.apiKey,
|
|
orderDirection: _sortOption.direction.apiKey,
|
|
)
|
|
.timeout(timeout);
|
|
|
|
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
|
|
final newFiles = filesData
|
|
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
final currentMap = <String, FileModel>{};
|
|
for (final file in _files) {
|
|
currentMap[file.path] = file;
|
|
}
|
|
|
|
final newMap = <String, FileModel>{};
|
|
for (final file in newFiles) {
|
|
newMap[file.path] = file;
|
|
}
|
|
|
|
int added = 0;
|
|
int removed = 0;
|
|
int updated = 0;
|
|
|
|
final updatedFiles = <FileModel>[];
|
|
|
|
for (final file in newFiles) {
|
|
final existingFile = currentMap[file.path];
|
|
if (existingFile != null) {
|
|
if (existingFile.updatedAt != file.updatedAt ||
|
|
existingFile.size != file.size) {
|
|
updatedFiles.add(file);
|
|
updated++;
|
|
} else {
|
|
updatedFiles.add(existingFile);
|
|
}
|
|
} else {
|
|
updatedFiles.add(file);
|
|
added++;
|
|
}
|
|
}
|
|
|
|
for (final file in _files) {
|
|
if (!newMap.containsKey(file.path)) {
|
|
removed++;
|
|
}
|
|
}
|
|
|
|
final pagination = response['pagination'] as Map<String, dynamic>?;
|
|
setState(() {
|
|
_files = updatedFiles;
|
|
_nextPageToken = pagination?['next_token'] as String?;
|
|
_hasMore = _nextPageToken != null;
|
|
_contextHint = response['context_hint'] as String?;
|
|
});
|
|
|
|
return RefreshResult(added: added, removed: removed, updated: updated);
|
|
} on TimeoutException {
|
|
setState(() {
|
|
_errorMessage = '加载超时,请检查网络后重试';
|
|
});
|
|
return const RefreshResult(added: 0, removed: 0, updated: 0);
|
|
} catch (e) {
|
|
setState(() {
|
|
_errorMessage = e.toString();
|
|
});
|
|
return const RefreshResult(added: 0, removed: 0, updated: 0);
|
|
} finally {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_highlightTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|