import 'dart:async'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'package:url_launcher/url_launcher.dart'; import '../api/guangya_api.dart'; import '../models/cloud_file.dart'; enum FileSort { name, size, modifiedAt, createdAt, type } extension FileSortExt on FileSort { String get title { switch (this) { case FileSort.name: return '名称'; case FileSort.size: return '大小'; case FileSort.modifiedAt: return '修改时间'; case FileSort.createdAt: return '创建时间'; case FileSort.type: return '类型'; } } int get apiOrderBy { switch (this) { case FileSort.name: return 0; case FileSort.size: return 1; case FileSort.createdAt: return 2; case FileSort.modifiedAt: return 3; case FileSort.type: return 4; } } } enum SortDirection { ascending, descending } enum WorkspaceSection { files('全部', 'folder'), recentViewed('最近查看', 'clock'), recentRestored('最近转存', 'history'), photos('图片', 'image'), videos('视频', 'movie'), audio('音频', 'music_note'), documents('文档', 'description'), cloud('云下载', 'cloud_download'), shares('我的分享', 'share'), recycle('回收站', 'delete'), mediaLibrary('光鸭影视', 'movie_filter'); final String label; final String icon; const WorkspaceSection(this.label, this.icon); } class FileState { final WorkspaceSection section; final List files; final List folderPath; final bool isLoading; final int currentPage; final int pageSize; final int totalPages; final FileSort serverSort; final SortDirection serverSortDirection; final String? errorMessage; final String? statusMessage; final Set selectedIDs; final List? clipboard; final bool clipboardIsMove; const FileState({ this.section = WorkspaceSection.files, this.files = const [], this.folderPath = const [], this.isLoading = false, this.currentPage = 0, this.pageSize = 50, this.totalPages = 1, this.serverSort = FileSort.name, this.serverSortDirection = SortDirection.ascending, this.errorMessage, this.statusMessage, this.selectedIDs = const {}, this.clipboard, this.clipboardIsMove = false, }); bool get hasSelection => selectedIDs.isNotEmpty; int get selectedCount => selectedIDs.length; FileState copyWith({ WorkspaceSection? section, List? files, List? folderPath, bool? isLoading, int? currentPage, int? pageSize, int? totalPages, FileSort? serverSort, SortDirection? serverSortDirection, String? errorMessage, bool clearError = false, String? statusMessage, bool clearStatus = false, Set? selectedIDs, List? clipboard, bool clearClipboard = false, bool? clipboardIsMove, }) { return FileState( section: section ?? this.section, files: files ?? this.files, folderPath: folderPath ?? this.folderPath, isLoading: isLoading ?? this.isLoading, currentPage: currentPage ?? this.currentPage, pageSize: pageSize ?? this.pageSize, totalPages: totalPages ?? this.totalPages, serverSort: serverSort ?? this.serverSort, serverSortDirection: serverSortDirection ?? this.serverSortDirection, errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage), selectedIDs: selectedIDs ?? this.selectedIDs, clipboard: clearClipboard ? null : (clipboard ?? this.clipboard), clipboardIsMove: clipboardIsMove ?? this.clipboardIsMove, ); } } class FileNotifier extends StateNotifier { GuangyaAPI? _api; FileNotifier() : super(const FileState()); set api(GuangyaAPI value) => _api = value; String? get _currentParentID => state.folderPath.isNotEmpty ? state.folderPath.last.id : null; void setSection(WorkspaceSection section) { final preservePath = section == WorkspaceSection.mediaLibrary; state = state.copyWith( section: section, files: [], folderPath: preservePath ? state.folderPath : [], currentPage: 0, selectedIDs: {}, ); loadFiles(); } Future loadFiles({String? parentID}) async { if (_api == null) return; state = state.copyWith(isLoading: true, clearError: true); try { final result = await _fetchFiles(parentID ?? _currentParentID); final extracted = _extractFiles(result); final totalPages = _extractTotalPages(result, extracted.length); state = state.copyWith(files: extracted, totalPages: totalPages); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } finally { state = state.copyWith(isLoading: false); } } Future> _fetchFiles(String? parentID) async { switch (state.section) { case WorkspaceSection.files: return await _api!.fsFiles( parentID: parentID, page: state.currentPage, pageSize: state.pageSize, orderBy: state.serverSort.apiOrderBy, sortType: state.serverSortDirection == SortDirection.ascending ? 0 : 1, ); case WorkspaceSection.recentViewed: return await _api!.recentViewed(pageSize: 100); case WorkspaceSection.recentRestored: return await _api!.recentRestored(pageSize: 100); case WorkspaceSection.photos: return await _api!.fsFiles( parentID: '*', orderBy: 3, sortType: 1, fileTypes: [1], resType: 1, ); case WorkspaceSection.videos: return await _api!.fsFiles( parentID: '*', orderBy: 3, sortType: 1, fileTypes: [2], resType: 1, ); case WorkspaceSection.audio: return await _api!.fsFiles( parentID: '*', orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true, ); case WorkspaceSection.documents: return await _api!.fsFiles( parentID: '*', orderBy: 3, sortType: 1, fileTypes: [4], resType: 1, ); case WorkspaceSection.cloud: return await _api!.cloudTaskList(); case WorkspaceSection.shares: return await _api!.shareUserList(); case WorkspaceSection.recycle: return await _api!.fsFiles(orderBy: 10, dirType: 4); case WorkspaceSection.mediaLibrary: return { 'code': 0, 'data': {'list': [], 'total': 0}, }; } } Future navigateToFolder(CloudFile folder) async { final newPath = [...state.folderPath, folder]; state = state.copyWith( folderPath: newPath, currentPage: 0, selectedIDs: {}, ); await loadFiles(parentID: folder.id); } Future navigateBack() async { if (state.folderPath.isEmpty) return; final newPath = List.from(state.folderPath)..removeLast(); state = state.copyWith( folderPath: newPath, currentPage: 0, selectedIDs: {}, ); final parentID = newPath.isNotEmpty ? newPath.last.id : null; await loadFiles(parentID: parentID); } void navigateToPathIndex(int index) { if (index < 0) { state = state.copyWith( folderPath: const [], currentPage: 0, selectedIDs: {}, ); loadFiles(parentID: null); return; } if (index >= state.folderPath.length) return; final newPath = state.folderPath.sublist(0, index + 1); state = state.copyWith( folderPath: newPath, currentPage: 0, selectedIDs: {}, ); final parentID = index >= 0 ? newPath[index].id : null; loadFiles(parentID: parentID); } void toggleSelection(String id) { final newSelected = Set.from(state.selectedIDs); if (newSelected.contains(id)) { newSelected.remove(id); } else { newSelected.add(id); } state = state.copyWith(selectedIDs: newSelected); } void selectAll() { if (state.selectedIDs.length == state.files.length) { state = state.copyWith(selectedIDs: {}); } else { state = state.copyWith(selectedIDs: state.files.map((f) => f.id).toSet()); } } void clearSelection() { state = state.copyWith(selectedIDs: {}); } void setSort(FileSort sort) { SortDirection direction; if (state.serverSort == sort) { direction = state.serverSortDirection == SortDirection.ascending ? SortDirection.descending : SortDirection.ascending; } else { direction = SortDirection.ascending; } state = state.copyWith( serverSort: sort, serverSortDirection: direction, currentPage: 0, ); loadFiles(parentID: _currentParentID); } void nextPage() { if (state.currentPage < state.totalPages - 1) { state = state.copyWith(currentPage: state.currentPage + 1); loadFiles(parentID: _currentParentID); } } void prevPage() { if (state.currentPage > 0) { state = state.copyWith(currentPage: state.currentPage - 1); loadFiles(parentID: _currentParentID); } } void setPageSize(int size) { state = state.copyWith(pageSize: size, currentPage: 0); loadFiles(parentID: _currentParentID); } // ── File operations ───────────────────────────────────────────── Future createFolder(String name) async { if (_api == null) return; state = state.copyWith(statusMessage: '正在创建文件夹…'); try { await _api!.fsCreateDir(name, parentID: _currentParentID); state = state.copyWith(statusMessage: '文件夹已创建'); await loadFiles(parentID: _currentParentID); } catch (e) { state = state.copyWith(errorMessage: e.toString(), clearStatus: true); } } Future renameFile(CloudFile file, String newName) async { if (_api == null) return; try { await _api!.fsRename(file.id, newName); state = state.copyWith(statusMessage: '重命名成功'); await loadFiles(parentID: _currentParentID); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } Future deleteFiles(List files) async { if (_api == null) return; state = state.copyWith(statusMessage: '正在删除…'); try { await _api!.fsDelete(files.map((f) => f.id).toList()); state = state.copyWith( statusMessage: '已删除 ${files.length} 个项目', selectedIDs: {}, ); await loadFiles(parentID: _currentParentID); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } Future recycleFiles(List files) async { if (_api == null) return; try { await _api!.fsRecycle(files.map((f) => f.id).toList()); state = state.copyWith(statusMessage: '已移入回收站', selectedIDs: {}); await loadFiles(parentID: _currentParentID); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } Future clearRecycleBin() async { if (_api == null) return; try { await _api!.fsClearRecycleBin(); state = state.copyWith(statusMessage: '回收站已清空'); await loadFiles(); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } void copyToClipboard(List files) { state = state.copyWith(clipboard: files, clipboardIsMove: false); } void cutToClipboard(List files) { state = state.copyWith(clipboard: files, clipboardIsMove: true); } Future pasteFromClipboard() async { if (_api == null || state.clipboard == null) return; state = state.copyWith( statusMessage: state.clipboardIsMove ? '正在移动…' : '正在复制…', ); try { final ids = state.clipboard!.map((f) => f.id).toList(); if (state.clipboardIsMove) { await _api!.fsMove(ids, parentID: _currentParentID); } else { await _api!.fsCopy(ids, parentID: _currentParentID); } state = state.copyWith(statusMessage: '操作完成'); await loadFiles(parentID: _currentParentID); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } void clearClipboard() { state = state.copyWith(clearClipboard: true); } Future downloadFile(CloudFile file) async { if (_api == null) return; try { state = state.copyWith(statusMessage: '正在准备外部打开…'); final url = await _resolveOpenUrl(file); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { throw Exception('无法调用系统默认应用打开链接'); } state = state.copyWith(statusMessage: '已交给系统默认应用'); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } Future createShare(CloudFile file) async { if (_api == null) return; try { state = state.copyWith(statusMessage: '正在创建分享…'); final result = await _api!.shareCreate([file.id], title: file.name); final link = _findStringDeep(result, const [ 'url', 'shareUrl', 'share_url', 'link', ]); if (link != null) { await Clipboard.setData(ClipboardData(text: link)); state = state.copyWith(statusMessage: '分享链接已复制'); } else { state = state.copyWith(statusMessage: '分享已创建'); } } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } Future restoreFiles(List files) async { if (_api == null || files.isEmpty) return; try { state = state.copyWith(statusMessage: '正在恢复 ${files.length} 个项目…'); await _api!.fsRecycle(files.map((file) => file.id).toList()); state = state.copyWith(statusMessage: '已恢复 ${files.length} 个项目'); await loadFiles(parentID: _currentParentID); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } Future _resolveOpenUrl(CloudFile file) async { String? url; if (file.isVideo) { final detail = await _api!.fsDetail(file.id); final gcid = file.gcid ?? _findStringDeep(detail, const ['gcid', 'gcId']); if (gcid != null && gcid.isNotEmpty) { final videoResult = await _api!.vodDownloadURL(file.id, gcid); url = _findStringDeep(videoResult, const [ 'signedURL', 'signedUrl', 'url', 'downloadUrl', 'download_url', 'dlink', ]); } } if (url == null) { final result = await _api!.downloadURL(file.id); url = _findStringDeep(result, const [ 'url', 'downloadUrl', 'download_url', 'dlink', ]); } final uri = url == null ? null : Uri.tryParse(url); if (uri == null || !uri.hasScheme) throw Exception('响应缺少可打开的签名链接'); return uri; } Future uploadLocalFiles(List files, {String? parentID}) async { if (_api == null || files.isEmpty) return; final targetParentID = parentID ?? _currentParentID; state = state.copyWith(statusMessage: '正在上传 ${files.length} 个文件…'); var completed = 0; try { for (final file in files) { if (!await file.exists()) continue; state = state.copyWith( statusMessage: '正在上传 ${completed + 1}/${files.length}:${file.uri.pathSegments.last}', ); await _api!.fileUpload(file, parentID: targetParentID); completed += 1; } state = state.copyWith(statusMessage: '已上传 $completed 个文件'); await loadFiles(parentID: _currentParentID); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } Future moveFilesTo(List files, {String? parentID}) async { if (_api == null || files.isEmpty) return; state = state.copyWith(statusMessage: '正在移动 ${files.length} 个项目…'); try { await _api!.fsMove( files.map((file) => file.id).toList(), parentID: parentID, ); state = state.copyWith(statusMessage: '移动完成', selectedIDs: {}); await loadFiles(parentID: _currentParentID); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } } void clearError() { state = state.copyWith(clearError: true); } void clearStatus() { state = state.copyWith(clearStatus: true); } // ── Helpers ───────────────────────────────────────────────────── List _extractFiles(Map json) { final result = []; final seen = {}; void visit(dynamic value) { if (value is Map) { final map = Map.from(value); try { final file = CloudFile.fromJson(map); if (seen.add(file.id)) result.add(file); } catch (_) {} for (final v in map.values) { visit(v); } } else if (value is List) { for (final v in value) { visit(v); } } } final preferred = _findArrayDeep(json, const [ 'list', 'files', 'fileList', 'file_list', 'items', 'records', 'rows', 'dataList', 'resList', 'resourceList', ]); if (preferred != null) { visit(preferred); return result; } visit(json); return result; } int _extractTotalPages(Map json, int itemCount) { final explicitPages = _extractInt(json, [ 'totalPages', 'pages', 'pageCount', ]); if (explicitPages != null && explicitPages > 0) return explicitPages; final totalItems = _extractInt(json, ['total', 'totalCount', 'count']); if (totalItems == null || totalItems <= 0) { return itemCount < state.pageSize ? 1 : state.currentPage + 2; } return (totalItems / state.pageSize).ceil().clamp(1, 1 << 31).toInt(); } static List? _findArrayDeep( Map json, List keys, ) { for (final key in keys) { final v = json[key]; if (v is List) return v; } const preferredKeys = ['data', 'result', 'payload']; for (final key in preferredKeys) { final v = json[key]; if (v is Map) { final found = _findArrayDeep(Map.from(v), keys); if (found != null) return found; } } for (final entry in json.entries) { if (preferredKeys.contains(entry.key)) continue; final v = entry.value; if (v is Map) { final found = _findArrayDeep(Map.from(v), keys); if (found != null) return found; } } return null; } static int? _extractInt(Map json, List keys) { for (final key in keys) { final v = json[key]; if (v != null) return v is int ? v : int.tryParse(v.toString()); } for (final entry in json.entries) { if (entry.value is Map) { final found = _extractInt(entry.value as Map, keys); if (found != null) return found; } } return null; } static String? _findStringDeep(Map json, List keys) { for (final key in keys) { final v = json[key]; if (v != null && v.toString().isNotEmpty) return v.toString(); } for (final entry in json.entries) { if (entry.value is Map) { final found = _findStringDeep( entry.value as Map, keys, ); if (found != null) return found; } } return null; } } final fileProvider = StateNotifierProvider( (ref) => FileNotifier(), );