Files
suixinkan_uikit/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift

888 lines
37 KiB
Swift
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.

//
// CloudDriveListViewController.swift
// suixinkan
//
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 navigationDivider = UIView()
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 = CloudDriveMenuButton()
private let filterButton = CloudDriveMenuButton()
private let displayModeButton = UIButton(type: .system)
private let contentDivider = UIView()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, CloudFile>!
private let refreshControl = UIRefreshControl()
private let loadingMoreIndicator = UIActivityIndicatorView(style: .medium)
private let uploadButton = UIButton(type: .system)
private let bottomBar = UIView()
private var appliedDisplayMode: CloudDriveDisplayMode?
private var lastInteractionTimes: [String: TimeInterval] = [:]
private var previousStandardAppearance: UINavigationBarAppearance?
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
private var previousCompactAppearance: UINavigationBarAppearance?
private var previousNavigationTintColor: UIColor?
///
init(
api: (any CloudDriveServing)? = nil,
transferManager: CloudTransferManager? = nil
) {
self.api = api ?? NetworkServices.shared.cloudDriveAPI
self.transferManager = transferManager ?? .shared
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
let titleLabel = UILabel()
titleLabel.text = "相册云盘"
titleLabel.textColor = UIColor(hex: 0x333333)
titleLabel.font = .systemFont(ofSize: 18)
navigationItem.titleView = titleLabel
let backButton = UIButton(type: .system)
backButton.setImage(UIImage(systemName: "chevron.left")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)), for: .normal)
backButton.tintColor = .black
backButton.accessibilityLabel = "返回"
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
backButton.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)
let transferButton = UIButton(type: .system)
transferButton.setImage(
CloudDriveAsset.resizedImage(
named: "icon_cloud_storage_transit",
fallbackSystemName: "arrow.up.arrow.down.circle",
size: CGSize(width: 20, height: 20)
),
for: .normal
)
transferButton.imageView?.contentMode = .scaleAspectFit
transferButton.accessibilityLabel = "传输管理"
transferButton.addTarget(self, action: #selector(openTransferManager), for: .touchUpInside)
transferButton.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: transferButton)
}
override func setupUI() {
view.backgroundColor = .white
navigationDivider.backgroundColor = UIColor(hex: 0xEEEEEE)
pathScrollView.showsHorizontalScrollIndicator = false
pathScrollView.alwaysBounceHorizontal = false
pathStackView.axis = .horizontal
pathStackView.alignment = .center
pathStackView.spacing = 0
pathScrollView.addSubview(pathStackView)
searchContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
searchContainer.layer.cornerRadius = 4
searchIconView.contentMode = .scaleAspectFit
searchField.attributedPlaceholder = NSAttributedString(
string: "搜索文件名称",
attributes: [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor(hex: 0xB6BECA),
]
)
searchField.font = .systemFont(ofSize: 14)
searchField.textColor = UIColor(hex: 0x333333)
searchField.returnKeyType = .search
searchField.clearButtonMode = .never
searchField.delegate = self
searchContainer.addSubview(searchIconView)
searchContainer.addSubview(searchField)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
displayModeButton.backgroundColor = .white
displayModeButton.imageView?.contentMode = .scaleAspectFit
displayModeButton.accessibilityLabel = "切换展示方式"
contentDivider.backgroundColor = UIColor(hex: 0xEEEEEE)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.refreshControl = refreshControl
collectionView.register(CloudDriveFileCell.self, forCellWithReuseIdentifier: CloudDriveFileCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Int, CloudFile>(collectionView: collectionView) { [weak self] collectionView, indexPath, file in
guard let self,
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: CloudDriveFileCell.reuseIdentifier,
for: indexPath
) as? CloudDriveFileCell else {
return UICollectionViewCell()
}
cell.apply(file: file, mode: self.viewModel.displayMode)
cell.onDetails = { [weak self] in
guard let self, self.shouldHandleInteraction("details_\(file.id)") else { return }
self.showControlSheet(for: file)
}
return cell
}
loadingMoreIndicator.color = AppColor.primary
collectionView.addSubview(loadingMoreIndicator)
bottomBar.backgroundColor = .white
bottomBar.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
bottomBar.layer.borderWidth = 0.5
uploadButton.setTitle("上传", for: .normal)
uploadButton.setTitleColor(.white, for: .normal)
uploadButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
uploadButton.backgroundColor = AppColor.primary
uploadButton.layer.cornerRadius = 10
uploadButton.accessibilityLabel = "上传"
[navigationDivider, pathScrollView, searchContainer, sortButton, filterButton, displayModeButton, contentDivider, collectionView, bottomBar]
.forEach(view.addSubview)
bottomBar.addSubview(uploadButton)
}
override func setupConstraints() {
navigationDivider.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
pathScrollView.snp.makeConstraints { make in
make.top.equalTo(navigationDivider.snp.bottom)
make.leading.trailing.equalToSuperview().inset(16)
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(16)
make.height.equalTo(36)
}
searchIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
searchField.snp.makeConstraints { make in
make.leading.equalTo(searchIconView.snp.trailing).offset(12)
make.top.bottom.equalToSuperview()
make.trailing.equalToSuperview().inset(8)
}
sortButton.snp.makeConstraints { make in
make.top.equalTo(searchContainer.snp.bottom).offset(16)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(36)
}
filterButton.snp.makeConstraints { make in
make.top.width.height.equalTo(sortButton)
make.leading.equalTo(sortButton.snp.trailing).offset(12)
}
displayModeButton.snp.makeConstraints { make in
make.centerY.equalTo(sortButton)
make.leading.equalTo(filterButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(6)
make.width.height.equalTo(35)
}
sortButton.snp.makeConstraints { make in
make.width.equalTo(filterButton)
}
contentDivider.snp.makeConstraints { make in
make.top.equalTo(sortButton.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
uploadButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(contentDivider.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
loadingMoreIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalTo(collectionView.frameLayoutGuide).inset(12)
}
}
override func bindActions() {
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside)
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
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) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: api, showLoading: true) }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyCloudNavigationBarAppearance()
updateInteractivePopGesture()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
restoreNavigationBarAppearance()
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
private func applyCloudNavigationBarAppearance() {
guard let navigationBar = navigationController?.navigationBar else { return }
previousStandardAppearance = navigationBar.standardAppearance
previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance
previousCompactAppearance = navigationBar.compactAppearance
previousNavigationTintColor = navigationBar.tintColor
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = .black
}
private func restoreNavigationBarAppearance() {
guard let navigationBar = navigationController?.navigationBar else { return }
if let previousStandardAppearance {
navigationBar.standardAppearance = previousStandardAppearance
}
navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance
navigationBar.compactAppearance = previousCompactAppearance
navigationBar.tintColor = previousNavigationTintColor
}
@MainActor
private func applyViewModel() {
rebuildBreadcrumb()
searchField.text = viewModel.searchText
sortButton.apply(
title: viewModel.sortType.title,
image: CloudDriveAsset.image(named: "icon_cloud_storage_list_sort", fallbackSystemName: "arrow.up.arrow.down")
)
filterButton.apply(
title: viewModel.filterType.displayTitle,
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_filter", fallbackSystemName: "line.3.horizontal.decrease.circle")
)
let isGrid = viewModel.displayMode == .grid
let displayIcon = CloudDriveAsset.resizedImage(
named: isGrid ? "icon_cloud_storage_show_type_list" : "icon_cloud_storage_show_type_table",
fallbackSystemName: isGrid ? "list.bullet" : "square.grid.2x2",
size: CGSize(width: 16, height: 16)
)
displayModeButton.setImage(displayIcon, for: .normal)
let shouldReconfigureCells = appliedDisplayMode != nil && appliedDisplayMode != viewModel.displayMode
if appliedDisplayMode != viewModel.displayMode {
appliedDisplayMode = viewModel.displayMode
collectionView.setCollectionViewLayout(makeLayout(), animated: false)
}
var snapshot = NSDiffableDataSourceSnapshot<Int, CloudFile>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.files)
if shouldReconfigureCells {
snapshot.reconfigureItems(viewModel.files)
}
dataSource.apply(snapshot, animatingDifferences: true)
if !viewModel.isRefreshing {
refreshControl.endRefreshing()
}
if viewModel.isLoading, !viewModel.files.isEmpty {
loadingMoreIndicator.startAnimating()
} else {
loadingMoreIndicator.stopAnimating()
}
if viewModel.isLoading, viewModel.files.isEmpty, !refreshControl.isRefreshing {
showLoading()
} else {
hideLoading()
}
updateInteractivePopGesture()
}
private func rebuildBreadcrumb() {
pathStackView.arrangedSubviews.forEach {
pathStackView.removeArrangedSubview($0)
$0.removeFromSuperview()
}
let homeButton = UIButton(type: .system)
homeButton.setImage(
CloudDriveAsset.image(named: "icon_cloud_storage_go_home", fallbackSystemName: "house.fill")?.withRenderingMode(.alwaysOriginal),
for: .normal
)
homeButton.imageView?.contentMode = .scaleAspectFit
homeButton.accessibilityLabel = "返回云盘首页"
homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside)
homeButton.snp.makeConstraints { make in
make.width.height.equalTo(16)
}
pathStackView.addArrangedSubview(homeButton)
pathStackView.addArrangedSubview(makeBreadcrumbSpacer(width: 8))
for (index, item) in viewModel.pathStack.enumerated() {
if index > 0 {
let container = UIView()
let arrow = UIImageView(
image: CloudDriveAsset.image(named: "icon_cloud_storage_current_path_arrow", fallbackSystemName: "chevron.right")
)
arrow.contentMode = .scaleAspectFit
container.addSubview(arrow)
container.snp.makeConstraints { make in make.width.equalTo(32) }
arrow.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.height.equalTo(16) }
pathStackView.addArrangedSubview(container)
}
let isLast = index == viewModel.pathStack.count - 1
let button = UIButton(type: .system)
button.setTitle(item.name, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: isLast ? .semibold : .regular)
button.setTitleColor(isLast ? .black : UIColor(hex: 0x4B5563), for: .normal)
button.titleLabel?.lineBreakMode = .byTruncatingTail
button.tag = index
button.isUserInteractionEnabled = !isLast
if !isLast {
button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside)
}
pathStackView.addArrangedSubview(button)
}
pathScrollView.layoutIfNeeded()
let rightOffset = max(0, pathScrollView.contentSize.width - pathScrollView.bounds.width)
pathScrollView.setContentOffset(CGPoint(x: rightOffset, y: 0), animated: false)
}
private func makeBreadcrumbSpacer(width: CGFloat) -> UIView {
let spacer = UIView()
spacer.snp.makeConstraints { make in make.width.equalTo(width) }
return spacer
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] _, environment in
switch self?.viewModel.displayMode ?? .grid {
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 itemHeight = itemWidth / 1.77 + 52
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemHeight)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemHeight)
)
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: 12, bottom: 12, trailing: 12)
section.interGroupSpacing = spacing
return section
case .list:
let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(72))
let item = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
return section
}
}
}
private func showControlSheet(for file: CloudFile) {
viewModel.setControlFile(file)
let sheet = CloudDriveActionSheetViewController(
title: "请选择",
layout: .controls,
items: [
.init(
title: "删除",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_delete", fallbackSystemName: "trash"),
isDestructive: false,
handler: { [weak self] in self?.confirmDelete() }
),
.init(
title: "下载",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_download", fallbackSystemName: "arrow.down.circle"),
isDestructive: false,
handler: { [weak self] in self?.download(file) }
),
.init(
title: "移动",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_move", fallbackSystemName: "folder"),
isDestructive: false,
handler: { [weak self] in self?.showMoveDialog() }
),
.init(
title: "详情",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "info.circle"),
isDestructive: false,
handler: { [weak self] in self?.showDetailDialog(file) }
),
]
)
present(sheet, animated: false)
}
private func confirmDelete() {
let dialog = CloudDriveConfirmationDialogViewController(
title: "删除确认",
message: "删除后无法恢复,确认删除吗?",
onConfirm: { [weak self] in
guard let self else { return }
Task { await self.viewModel.deleteCurrentFile(api: self.api) }
}
)
present(dialog, animated: false)
}
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 dialog = CloudDriveMoveDialogViewController(folders: viewModel.moveTargetFolders()) { [weak self] folderID in
guard let self else { return }
Task { await self.viewModel.moveCurrentFile(targetFolderId: folderID, api: self.api) }
}
present(dialog, animated: false)
}
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: false)
}
private func beginPick(fileType: Int) {
Task {
guard await viewModel.checkCanUpload(api: api) else { return }
await MainActor.run {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = 1
configuration.filter = fileType == 2 ? .images : .videos
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.view.tag = fileType
self.present(picker, animated: true)
}
}
}
private func showCreateFolderDialog() {
guard viewModel.pathStack.count < 5 else {
showToast("不能创建更深的文件夹")
return
}
let dialog = CloudDriveTextInputDialogViewController(
title: "新建文件夹",
placeholder: "请输入文件夹名称"
) { [weak self] name in
guard let self else { return }
Task { await self.viewModel.addFolder(name: name, api: self.api) }
}
present(dialog, animated: false)
}
private func showDropdown(
from sourceView: UIView,
items: [CloudDriveDropdownViewController.Item]
) {
present(CloudDriveDropdownViewController(sourceView: sourceView, items: items), animated: false)
}
private func updateInteractivePopGesture() {
navigationController?.interactivePopGestureRecognizer?.isEnabled = viewModel.pathStack.count == 1
}
private func shouldHandleInteraction(_ key: String, interval: TimeInterval = 0.5) -> Bool {
let now = ProcessInfo.processInfo.systemUptime
if let previous = lastInteractionTimes[key], now - previous < interval {
return false
}
lastInteractionTimes[key] = now
return true
}
@objc private func backTapped() {
guard shouldHandleInteraction("back", interval: 1) else { return }
Task {
let handled = await viewModel.navigateBack(api: api)
if !handled {
_ = await MainActor.run { self.navigationController?.popViewController(animated: true) }
}
}
}
@objc private func refreshTriggered() {
Task { await viewModel.refresh(api: api, showLoading: false) }
}
@objc private func sortTapped() {
guard shouldHandleInteraction("sort") else { return }
showDropdown(
from: sortButton,
items: CloudDriveSortType.allCases.map { type in
.init(title: type.title, isSelected: type == viewModel.sortType) { [weak self] in
guard let self else { return }
Task { await self.viewModel.chooseSortType(type, api: self.api) }
}
}
)
}
@objc private func filterTapped() {
guard shouldHandleInteraction("filter") else { return }
showDropdown(
from: filterButton,
items: CloudDriveFilterType.androidMenuOrder.map { type in
.init(title: type.menuTitle, isSelected: type == viewModel.filterType) { [weak self] in
guard let self else { return }
Task { await self.viewModel.chooseFilterType(type, api: self.api) }
}
}
)
}
@objc private func uploadTapped() {
guard shouldHandleInteraction("upload") else { return }
let sheet = CloudDriveActionSheetViewController(
title: "选择上传类型",
layout: .upload,
items: [
.init(
title: "上传图片",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_photo", fallbackSystemName: "photo"),
isDestructive: false,
handler: { [weak self] in self?.beginPick(fileType: 2) }
),
.init(
title: "上传视频",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_video", fallbackSystemName: "video"),
isDestructive: false,
handler: { [weak self] in self?.beginPick(fileType: 1) }
),
.init(
title: "新建文件夹",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_folder", fallbackSystemName: "folder.badge.plus"),
isDestructive: false,
handler: { [weak self] in self?.showCreateFolderDialog() }
),
]
)
present(sheet, animated: false)
}
@objc private func displayModeTapped() {
guard shouldHandleInteraction("display") else { return }
viewModel.toggleDisplayMode()
}
@objc private func openTransferManager() {
guard shouldHandleInteraction("transfer", interval: 1) else { return }
navigationController?.pushViewController(CloudDriveTransferViewController(manager: transferManager), animated: true)
}
@objc private func homeTapped() {
guard shouldHandleInteraction("home") else { return }
Task { await viewModel.goHome(api: api) }
}
@objc private func pathTapped(_ sender: UIButton) {
guard shouldHandleInteraction("path_\(sender.tag)") else { return }
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 attributes = try? FileManager.default.attributesOfItem(atPath: stagedURL.path)
let size = (attributes?[.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)")
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),
shouldHandleInteraction("file_\(file.id)") else { return }
Task {
if let preview = await viewModel.handleFileTap(file, api: api) {
await MainActor.run { self.openPreview(file: preview) }
}
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentSize.height > scrollView.bounds.height else { return }
let threshold = scrollView.contentSize.height - scrollView.bounds.height - 120
guard scrollView.contentOffset.y >= threshold else { return }
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 fileType = picker.view.tag
picker.dismiss(animated: true)
guard let result = results.first else { return }
stagePickedFile(result: result, fileType: fileType)
}
}
/// Android
private final class CloudDriveMenuButton: UIButton {
private let valueLabel = UILabel()
private let iconView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hex: 0xF4F4F4)
layer.cornerRadius = 4
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = UIColor(hex: 0x333333)
valueLabel.isUserInteractionEnabled = false
iconView.contentMode = .scaleAspectFit
iconView.isUserInteractionEnabled = false
addSubview(valueLabel)
addSubview(iconView)
valueLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8)
}
iconView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(title: String, image: UIImage?) {
valueLabel.text = title
iconView.image = image?.withRenderingMode(.alwaysOriginal)
accessibilityLabel = title
}
}
/// Android
private final class CloudDriveDropdownViewController: UIViewController {
///
struct Item {
let title: String
let isSelected: Bool
let handler: () -> Void
}
private weak var sourceView: UIView?
private let items: [Item]
private let dismissButton = UIButton(type: .custom)
private let menuView = UIView()
private let stackView = UIStackView()
///
init(sourceView: UIView, items: [Item]) {
self.sourceView = sourceView
self.items = items
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)
menuView.backgroundColor = .white
menuView.layer.cornerRadius = 8
menuView.layer.shadowColor = UIColor.black.cgColor
menuView.layer.shadowOpacity = 0.16
menuView.layer.shadowRadius = 10
menuView.layer.shadowOffset = CGSize(width: 0, height: 4)
stackView.axis = .vertical
view.addSubview(dismissButton)
view.addSubview(menuView)
menuView.addSubview(stackView)
dismissButton.snp.makeConstraints { make in make.edges.equalToSuperview() }
stackView.snp.makeConstraints { make in make.edges.equalToSuperview() }
items.enumerated().forEach { index, item in
let button = UIButton(type: .system)
var configuration = UIButton.Configuration.plain()
configuration.title = item.title
configuration.baseForegroundColor = item.isSelected ? AppColor.primary : UIColor(hex: 0x333333)
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
configuration.titleAlignment = .leading
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 14)
return attributes
}
button.configuration = configuration
button.tag = index
button.addTarget(self, action: #selector(itemTapped(_:)), for: .touchUpInside)
button.snp.makeConstraints { make in make.height.equalTo(48) }
stackView.addArrangedSubview(button)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard let sourceView else { return }
let sourceFrame = sourceView.convert(sourceView.bounds, to: view)
let width: CGFloat = 165
let height = CGFloat(items.count) * 48
let x = min(max(8, sourceFrame.minX), view.bounds.width - width - 8)
var y = sourceFrame.maxY
if y + height > view.bounds.height - 8 {
y = sourceFrame.minY - height
}
menuView.frame = CGRect(x: x, y: y, width: width, height: height)
}
@objc private func dismissTapped() {
dismiss(animated: false)
}
@objc private func itemTapped(_ sender: UIButton) {
guard items.indices.contains(sender.tag) else { return }
let handler = items[sender.tag].handler
dismiss(animated: false, completion: handler)
}
}