// // CloudDriveListViewController.swift // suixinkan // import Kingfisher import PhotosUI import SnapKit import UIKit import UniformTypeIdentifiers /// 相册云盘列表页,对齐 Android `CloudStorageListScreen`。 final class CloudDriveListViewController: BaseViewController { private let viewModel = CloudDriveListViewModel() private let api: any CloudDriveServing private let transferManager: CloudTransferManager private let pathScrollView = UIScrollView() private let pathStackView = UIStackView() private let searchContainer = UIView() private let searchIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_search", fallbackSystemName: "magnifyingglass")) private let searchField = UITextField() private let sortButton = UIButton(type: .system) private let filterButton = UIButton(type: .system) private let displayModeButton = UIButton(type: .system) private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private let uploadButton = AppButton(title: "上传") private let bottomBar = UIView() /// 初始化相册云盘列表页。 init( api: (any CloudDriveServing)? = nil, transferManager: CloudTransferManager = .shared ) { self.api = api ?? NetworkServices.shared.cloudDriveAPI self.transferManager = transferManager super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "相册云盘" navigationItem.rightBarButtonItem = UIBarButtonItem( image: CloudDriveAsset.image(named: "icon_cloud_storage_transit", fallbackSystemName: "arrow.up.arrow.down.circle"), style: .plain, target: self, action: #selector(openTransferManager) ) } override func setupUI() { view.backgroundColor = .white pathScrollView.showsHorizontalScrollIndicator = false pathStackView.axis = .horizontal pathStackView.alignment = .center pathStackView.spacing = AppSpacing.xs pathScrollView.addSubview(pathStackView) searchContainer.backgroundColor = AppColor.inputBackground searchContainer.layer.cornerRadius = AppRadius.xs searchIconView.tintColor = AppColor.textTertiary searchField.placeholder = "搜索文件名称" searchField.font = .app(.body) searchField.textColor = AppColor.textPrimary searchField.returnKeyType = .search searchField.delegate = self searchContainer.addSubview(searchIconView) searchContainer.addSubview(searchField) configureFilterButton(sortButton) configureFilterButton(filterButton) displayModeButton.tintColor = AppColor.textPrimary displayModeButton.backgroundColor = .white displayModeButton.layer.cornerRadius = AppRadius.xs collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = .white collectionView.alwaysBounceVertical = true collectionView.delegate = self collectionView.register(CloudDriveFileCell.self, forCellWithReuseIdentifier: CloudDriveFileCell.reuseIdentifier) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, file in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: CloudDriveFileCell.reuseIdentifier, for: indexPath ) as! CloudDriveFileCell cell.apply(file: file, mode: self?.viewModel.displayMode ?? .grid) cell.onDetails = { [weak self] in self?.showControlSheet(for: file) } return cell } bottomBar.backgroundColor = .white bottomBar.layer.borderColor = AppColor.border.cgColor bottomBar.layer.borderWidth = 0.5 view.addSubview(pathScrollView) view.addSubview(searchContainer) view.addSubview(sortButton) view.addSubview(filterButton) view.addSubview(displayModeButton) view.addSubview(collectionView) view.addSubview(bottomBar) bottomBar.addSubview(uploadButton) } override func setupConstraints() { pathScrollView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(52) } pathStackView.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalToSuperview() } searchContainer.snp.makeConstraints { make in make.top.equalTo(pathScrollView.snp.bottom).offset(2) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(36) } searchIconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(AppSpacing.sm) make.centerY.equalToSuperview() make.width.height.equalTo(16) } searchField.snp.makeConstraints { make in make.leading.equalTo(searchIconView.snp.trailing).offset(AppSpacing.sm) make.top.bottom.trailing.equalToSuperview().inset(AppSpacing.xs) } sortButton.snp.makeConstraints { make in make.top.equalTo(searchContainer.snp.bottom).offset(AppSpacing.md) make.leading.equalToSuperview().offset(AppSpacing.md) make.height.equalTo(36) } filterButton.snp.makeConstraints { make in make.top.width.height.equalTo(sortButton) make.leading.equalTo(sortButton.snp.trailing).offset(AppSpacing.sm) } displayModeButton.snp.makeConstraints { make in make.centerY.equalTo(sortButton) make.leading.equalTo(filterButton.snp.trailing).offset(AppSpacing.sm) make.trailing.equalToSuperview().inset(6) make.width.height.equalTo(36) } sortButton.snp.makeConstraints { make in make.width.equalTo(filterButton) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } uploadButton.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md) } collectionView.snp.makeConstraints { make in make.top.equalTo(sortButton.snp.bottom).offset(AppSpacing.md) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } } override func bindActions() { uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside) viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } transferManager.onUploadCompleted = { [weak self] folderId in guard let self else { return } Task { await self.viewModel.handleUploadCompleted(parentFolderId: folderId, api: self.api) } } transferManager.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } configureMenus() } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.refresh(api: api, showLoading: true) } } @MainActor private func applyViewModel() { rebuildBreadcrumb() searchField.text = viewModel.searchText sortButton.setTitle(viewModel.sortType.title, for: .normal) filterButton.setTitle(viewModel.filterType.title, for: .normal) let displayIconName = viewModel.displayMode == .grid ? "icon_cloud_storage_show_type_list" : "icon_cloud_storage_show_type_table" let displayFallback = viewModel.displayMode == .grid ? "list.bullet" : "square.grid.2x2" displayModeButton.setImage(CloudDriveAsset.image(named: displayIconName, fallbackSystemName: displayFallback), for: .normal) sortButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_list_sort", fallbackSystemName: "arrow.up.arrow.down"), for: .normal) filterButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_filter", fallbackSystemName: "line.3.horizontal.decrease.circle"), for: .normal) collectionView.setCollectionViewLayout(makeLayout(), animated: false) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.files) dataSource.apply(snapshot, animatingDifferences: true) if viewModel.isLoading && viewModel.files.isEmpty { showLoading() } else { hideLoading() } } private func rebuildBreadcrumb() { pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } let homeButton = UIButton(type: .system) homeButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_go_home", fallbackSystemName: "house.fill"), for: .normal) homeButton.tintColor = AppColor.textPrimary homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside) homeButton.snp.makeConstraints { make in make.width.height.equalTo(24) } pathStackView.addArrangedSubview(homeButton) for (index, item) in viewModel.pathStack.enumerated() { if index > 0 { let arrow = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_current_path_arrow", fallbackSystemName: "chevron.right")) arrow.tintColor = AppColor.textTertiary arrow.snp.makeConstraints { make in make.width.equalTo(8) } pathStackView.addArrangedSubview(arrow) } let button = UIButton(type: .system) button.setTitle(item.name, for: .normal) button.titleLabel?.font = .app(index == viewModel.pathStack.count - 1 ? .body : .caption) button.setTitleColor(index == viewModel.pathStack.count - 1 ? .black : AppColor.textSecondary, for: .normal) button.tag = index button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside) pathStackView.addArrangedSubview(button) } } private func configureMenus() { sortButton.menu = UIMenu(children: CloudDriveSortType.allCases.map { type in UIAction(title: type.title) { [weak self] _ in guard let self else { return } Task { await self.viewModel.chooseSortType(type, api: self.api) } } }) sortButton.showsMenuAsPrimaryAction = true filterButton.menu = UIMenu(children: CloudDriveFilterType.allCases.map { type in UIAction(title: type.title) { [weak self] _ in guard let self else { return } Task { await self.viewModel.chooseFilterType(type, api: self.api) } } }) filterButton.showsMenuAsPrimaryAction = true } private func configureFilterButton(_ button: UIButton) { button.backgroundColor = AppColor.inputBackground button.layer.cornerRadius = AppRadius.xs button.titleLabel?.font = .app(.caption) button.setTitleColor(AppColor.textPrimary, for: .normal) button.contentHorizontalAlignment = .left button.imageView?.contentMode = .scaleAspectFit button.tintColor = AppColor.textPrimary button.semanticContentAttribute = .forceLeftToRight button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.xs) button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.sm, bottom: 0, right: AppSpacing.sm) } private func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { [weak self] _, environment in let mode = self?.viewModel.displayMode ?? .grid switch mode { case .grid: let columns = 2 let spacing: CGFloat = 15 let horizontalInset: CGFloat = 12 let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 let itemWidth = (availableWidth - spacing) / CGFloat(columns) let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemWidth * 0.82)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemWidth * 0.82)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: columns) group.interItemSpacing = .fixed(spacing) let section = NSCollectionLayoutSection(group: group) section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: horizontalInset, bottom: 12, trailing: horizontalInset) section.interGroupSpacing = spacing return section case .list: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(72)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: AppSpacing.md, bottom: AppSpacing.md, trailing: AppSpacing.md) return section } } } private func showControlSheet(for file: CloudFile) { viewModel.setControlFile(file) let sheet = CloudDriveActionSheetViewController(title: "请选择", items: [ CloudDriveActionSheetViewController.Item( title: "删除", image: CloudDriveAsset.image(named: "icon_cloud_storage_file_delete", fallbackSystemName: "trash"), isDestructive: true ) { [weak self] in self?.confirmDelete() }, CloudDriveActionSheetViewController.Item( title: "下载", image: CloudDriveAsset.image(named: "icon_cloud_storage_file_download", fallbackSystemName: "arrow.down.circle"), isDestructive: false ) { [weak self] in self?.download(file) }, CloudDriveActionSheetViewController.Item( title: "移动", image: CloudDriveAsset.image(named: "icon_cloud_storage_file_move", fallbackSystemName: "folder"), isDestructive: false ) { [weak self] in self?.showMoveDialog() }, CloudDriveActionSheetViewController.Item( title: "详情", image: CloudDriveAsset.image(named: "icon_cloud_storage_file_item_details", fallbackSystemName: "info.circle"), isDestructive: false ) { [weak self] in self?.showDetailDialog(file) } ]) present(sheet, animated: true) } private func confirmDelete() { showAlert(title: "删除确认", message: "删除后无法恢复,确认删除吗?") { [weak self] in guard let self else { return } Task { await self.viewModel.deleteCurrentFile(api: self.api) } } } private func download(_ file: CloudFile) { guard file.isSelectableMedia else { showToast("当前类型不支持下载") return } guard !file.fileUrl.isEmpty else { showToast("文件地址不存在") return } transferManager.addDownloadTask(fileURL: file.fileUrl, fileName: file.name, fileType: file.type, fileSize: file.fileSize) } private func showMoveDialog() { let items = viewModel.moveTargetFolders().map { folder in CloudDriveActionSheetViewController.Item( title: folder.name, image: CloudDriveAsset.image(named: "icon_cloud_storage_file_type_folder", fallbackSystemName: "folder"), isDestructive: false ) { [weak self] in guard let self else { return } Task { await self.viewModel.moveCurrentFile(targetFolderId: folder.id, api: self.api) } } } present(CloudDriveActionSheetViewController(title: "移动到", items: items), animated: true) } private func showDetailDialog(_ file: CloudFile) { let sheet = CloudDriveDetailSheetViewController(file: file) { [weak self] name in guard let self else { return } Task { await self.viewModel.saveCurrentFileName(name, api: self.api) } } present(sheet, animated: true) } @objc private func uploadTapped() { let sheet = CloudDriveActionSheetViewController(title: "选择上传类型", items: [ CloudDriveActionSheetViewController.Item( title: "上传图片", image: CloudDriveAsset.image(named: "icon_cloud_storage_add_photo", fallbackSystemName: "photo"), isDestructive: false ) { [weak self] in self?.beginPick(type: 1) }, CloudDriveActionSheetViewController.Item( title: "上传视频", image: CloudDriveAsset.image(named: "icon_cloud_storage_add_video", fallbackSystemName: "video"), isDestructive: false ) { [weak self] in self?.beginPick(type: 2) }, CloudDriveActionSheetViewController.Item( title: "新建文件夹", image: CloudDriveAsset.image(named: "icon_cloud_storage_add_folder", fallbackSystemName: "folder.badge.plus"), isDestructive: false ) { [weak self] in self?.showCreateFolderDialog() } ]) present(sheet, animated: true) } private func beginPick(type: Int) { Task { guard await viewModel.checkCanUpload(api: api) else { return } await MainActor.run { var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.selectionLimit = 1 configuration.filter = type == 1 ? .images : .videos let picker = PHPickerViewController(configuration: configuration) picker.delegate = self picker.view.tag = type present(picker, animated: true) } } } private func showCreateFolderDialog() { guard viewModel.pathStack.count < 5 else { showToast("不能创建更深的文件夹") return } let alert = UIAlertController(title: "新建文件夹", message: nil, preferredStyle: .alert) alert.addTextField { textField in textField.placeholder = "请输入文件夹名称" } alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self, weak alert] _ in guard let self else { return } let name = alert?.textFields?.first?.text ?? "" Task { await self.viewModel.addFolder(name: name, api: self.api) } }) present(alert, animated: true) } @objc private func displayModeTapped() { viewModel.toggleDisplayMode() } @objc private func openTransferManager() { navigationController?.pushViewController(CloudDriveTransferViewController(manager: transferManager), animated: true) } @objc private func homeTapped() { Task { await viewModel.goHome(api: api) } } @objc private func pathTapped(_ sender: UIButton) { Task { await viewModel.navigateToPathIndex(sender.tag, api: api) } } private func openPreview(file: CloudFile) { navigationController?.pushViewController(CloudDrivePreviewViewController(file: file), animated: true) } private func stagePickedFile(result: PHPickerResult, fileType: Int) { let provider = result.itemProvider let typeIdentifier = fileType == 2 ? UTType.image.identifier : UTType.movie.identifier provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, error in guard let self else { return } if let error { Task { @MainActor in self.showToast(error.localizedDescription) } return } guard let url else { return } do { let stagedURL = try Self.copyPickedFileToStage(url: url, suggestedName: provider.suggestedName, fileType: fileType) let size = (try? FileManager.default.attributesOfItem(atPath: stagedURL.path)[.size] as? NSNumber)?.int64Value ?? 0 Task { @MainActor in self.transferManager.addUploadTask( localFileURL: stagedURL, fileName: stagedURL.lastPathComponent, fileType: fileType, fileSize: size, parentFolderId: self.viewModel.currentFolderID ) } } catch { Task { @MainActor in self.showToast(error.localizedDescription) } } } } private static func copyPickedFileToStage(url: URL, suggestedName: String?, fileType: Int) throws -> URL { let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cloud_drive_uploads", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let ext = url.pathExtension.isEmpty ? (fileType == 2 ? "jpg" : "mp4") : url.pathExtension let baseName = CloudTransferFileName.sanitized(suggestedName ?? "cloud_upload") let fileName = baseName.lowercased().hasSuffix(".\(ext.lowercased())") ? baseName : "\(baseName).\(ext)" let destination = directory.appendingPathComponent("\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))_\(fileName)") if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } try FileManager.default.copyItem(at: url, to: destination) return destination } } extension CloudDriveListViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { viewModel.updateSearchText(textField.text ?? "") textField.resignFirstResponder() Task { await viewModel.refresh(api: api, showLoading: true) } return true } } extension CloudDriveListViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let file = dataSource.itemIdentifier(for: indexPath) else { return } Task { if let preview = await viewModel.handleFileTap(file, api: api) { await MainActor.run { self.openPreview(file: preview) } } } } func scrollViewDidScroll(_ scrollView: UIScrollView) { let offsetY = scrollView.contentOffset.y let contentHeight = scrollView.contentSize.height let frameHeight = scrollView.frame.size.height if offsetY > contentHeight - frameHeight - 120 { let lastIndex = max(0, viewModel.files.count - 1) Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: lastIndex, api: api) } } } } extension CloudDriveListViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { let type = picker.view.tag picker.dismiss(animated: true) guard let result = results.first else { return } stagePickedFile(result: result, fileType: type == 2 ? 1 : 2) } }