feat: 添加云盘与消息中心功能
This commit is contained in:
@ -0,0 +1,137 @@
|
||||
//
|
||||
// CloudDriveActionSheetViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 云盘底部操作弹层,用于同步 Android 云盘上传与文件操作菜单。
|
||||
final class CloudDriveActionSheetViewController: UIViewController {
|
||||
/// 云盘底部弹层操作项。
|
||||
struct Item {
|
||||
/// 操作文案。
|
||||
let title: String
|
||||
/// 操作图标。
|
||||
let image: UIImage?
|
||||
/// 是否展示为危险操作。
|
||||
let isDestructive: Bool
|
||||
/// 点击后的业务动作。
|
||||
let handler: () -> Void
|
||||
}
|
||||
|
||||
private let sheetTitle: String?
|
||||
private let items: [Item]
|
||||
private let dimView = UIView()
|
||||
private let panelView = UIView()
|
||||
private let stackView = UIStackView()
|
||||
|
||||
/// 初始化云盘底部弹层。
|
||||
init(title: String? = nil, items: [Item]) {
|
||||
self.sheetTitle = title
|
||||
self.items = items
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .clear
|
||||
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
|
||||
|
||||
panelView.backgroundColor = .white
|
||||
panelView.layer.cornerRadius = 18
|
||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
panelView.clipsToBounds = true
|
||||
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 0
|
||||
|
||||
view.addSubview(dimView)
|
||||
view.addSubview(panelView)
|
||||
panelView.addSubview(stackView)
|
||||
|
||||
if let sheetTitle {
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = sheetTitle
|
||||
titleLabel.font = .app(.bodyMedium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.height.equalTo(54)
|
||||
}
|
||||
stackView.addArrangedSubview(titleLabel)
|
||||
}
|
||||
|
||||
for item in items {
|
||||
stackView.addArrangedSubview(makeItemButton(item))
|
||||
}
|
||||
|
||||
let spacer = UIView()
|
||||
spacer.backgroundColor = AppColor.pageBackground
|
||||
spacer.snp.makeConstraints { make in
|
||||
make.height.equalTo(8)
|
||||
}
|
||||
stackView.addArrangedSubview(spacer)
|
||||
|
||||
let cancelButton = UIButton(type: .system)
|
||||
cancelButton.setTitle("取消", for: .normal)
|
||||
cancelButton.setTitleColor(AppColor.textPrimary, for: .normal)
|
||||
cancelButton.titleLabel?.font = .app(.body)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(54)
|
||||
}
|
||||
stackView.addArrangedSubview(cancelButton)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
dimView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
panelView.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeItemButton(_ item: Item) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(item.title, for: .normal)
|
||||
button.setImage(item.image, for: .normal)
|
||||
button.tintColor = item.isDestructive ? AppColor.danger : AppColor.textPrimary
|
||||
button.setTitleColor(item.isDestructive ? AppColor.danger : AppColor.textPrimary, for: .normal)
|
||||
button.titleLabel?.font = .app(.body)
|
||||
button.contentHorizontalAlignment = .left
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.xl, bottom: 0, right: AppSpacing.xl)
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.md)
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.dismiss(animated: true) {
|
||||
item.handler()
|
||||
}
|
||||
}, for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(54)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
//
|
||||
// CloudDriveDetailSheetViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 云盘文件详情底部表单,用于查看文件信息并修改名称。
|
||||
final class CloudDriveDetailSheetViewController: UIViewController {
|
||||
private let file: CloudFile
|
||||
private let onSave: (String) -> Void
|
||||
private let dimView = UIView()
|
||||
private let panelView = UIView()
|
||||
private let titleLabel = UILabel()
|
||||
private let nameTitleLabel = UILabel()
|
||||
private let nameField = UITextField()
|
||||
private let infoLabel = UILabel()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let saveButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化云盘文件详情表单。
|
||||
init(file: CloudFile, onSave: @escaping (String) -> Void) {
|
||||
self.file = file
|
||||
self.onSave = onSave
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .clear
|
||||
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
|
||||
|
||||
panelView.backgroundColor = .white
|
||||
panelView.layer.cornerRadius = 18
|
||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
panelView.clipsToBounds = true
|
||||
|
||||
titleLabel.text = "详情"
|
||||
titleLabel.font = .app(.bodyMedium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
nameTitleLabel.text = "文件名称"
|
||||
nameTitleLabel.font = .app(.caption)
|
||||
nameTitleLabel.textColor = AppColor.textSecondary
|
||||
|
||||
nameField.text = file.name
|
||||
nameField.font = .app(.body)
|
||||
nameField.textColor = AppColor.textPrimary
|
||||
nameField.clearButtonMode = .whileEditing
|
||||
nameField.returnKeyType = .done
|
||||
nameField.delegate = self
|
||||
nameField.backgroundColor = AppColor.inputBackground
|
||||
nameField.layer.cornerRadius = AppRadius.xs
|
||||
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1))
|
||||
nameField.leftViewMode = .always
|
||||
|
||||
infoLabel.text = "\(file.createdAt) | \(CloudDriveFormatter.fileSize(file.fileSize))\n类型:\(CloudDriveFormatter.typeName(file.type))"
|
||||
infoLabel.font = .app(.caption)
|
||||
infoLabel.textColor = AppColor.textTertiary
|
||||
infoLabel.numberOfLines = 0
|
||||
|
||||
cancelButton.setTitle("取消", for: .normal)
|
||||
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
|
||||
cancelButton.titleLabel?.font = .app(.body)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
|
||||
saveButton.setTitle("保存", for: .normal)
|
||||
saveButton.setTitleColor(.white, for: .normal)
|
||||
saveButton.titleLabel?.font = .app(.bodyMedium)
|
||||
saveButton.backgroundColor = AppColor.primary
|
||||
saveButton.layer.cornerRadius = AppRadius.xs
|
||||
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(dimView)
|
||||
view.addSubview(panelView)
|
||||
[titleLabel, nameTitleLabel, nameField, infoLabel, cancelButton, saveButton].forEach(panelView.addSubview)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
dimView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
panelView.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(54)
|
||||
}
|
||||
nameTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
nameField.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.trailing.equalTo(nameTitleLabel)
|
||||
make.height.equalTo(42)
|
||||
}
|
||||
infoLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameField.snp.bottom).offset(AppSpacing.md)
|
||||
make.leading.trailing.equalTo(nameTitleLabel)
|
||||
}
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(infoLabel.snp.bottom).offset(AppSpacing.lg)
|
||||
make.leading.equalToSuperview().offset(AppSpacing.md)
|
||||
make.height.equalTo(44)
|
||||
make.width.equalTo(saveButton)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
|
||||
}
|
||||
saveButton.snp.makeConstraints { make in
|
||||
make.top.bottom.width.equalTo(cancelButton)
|
||||
make.leading.equalTo(cancelButton.snp.trailing).offset(AppSpacing.md)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func saveTapped() {
|
||||
let name = nameField.text ?? ""
|
||||
dismiss(animated: true) { [onSave] in
|
||||
onSave(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CloudDriveDetailSheetViewController: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField.resignFirstResponder()
|
||||
return true
|
||||
}
|
||||
}
|
||||
189
suixinkan/UI/CloudDrive/CloudDriveFileCell.swift
Normal file
189
suixinkan/UI/CloudDrive/CloudDriveFileCell.swift
Normal file
@ -0,0 +1,189 @@
|
||||
//
|
||||
// CloudDriveFileCell.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 云盘文件 Cell,支持 Android 宫格与列表两种样式。
|
||||
final class CloudDriveFileCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "CloudDriveFileCell"
|
||||
|
||||
var onDetails: (() -> Void)?
|
||||
|
||||
private let thumbnailView = UIImageView()
|
||||
private let folderIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_folder_type", fallbackSystemName: "folder.fill"))
|
||||
private let playIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_play", fallbackSystemName: "play.circle.fill"))
|
||||
private let extensionLabel = CloudDrivePaddingLabel()
|
||||
private let nameLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let moreButton = UIButton(type: .system)
|
||||
|
||||
private var gridConstraints: [Constraint] = []
|
||||
private var listConstraints: [Constraint] = []
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
thumbnailView.kf.cancelDownloadTask()
|
||||
thumbnailView.image = nil
|
||||
folderIconView.isHidden = true
|
||||
playIconView.isHidden = true
|
||||
extensionLabel.isHidden = true
|
||||
onDetails = nil
|
||||
}
|
||||
|
||||
/// 应用云盘文件数据。
|
||||
func apply(file: CloudFile, mode: CloudDriveDisplayMode) {
|
||||
nameLabel.text = file.name
|
||||
detailLabel.text = mode == .grid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file)
|
||||
configureThumbnail(file)
|
||||
gridConstraints.forEach { mode == .grid ? $0.activate() : $0.deactivate() }
|
||||
listConstraints.forEach { mode == .list ? $0.activate() : $0.deactivate() }
|
||||
contentView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0
|
||||
contentView.layer.borderWidth = mode == .grid ? 1 : 0
|
||||
contentView.backgroundColor = mode == .grid ? .white : .clear
|
||||
thumbnailView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0
|
||||
nameLabel.textAlignment = mode == .grid ? .left : .left
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
contentView.clipsToBounds = true
|
||||
contentView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
|
||||
|
||||
thumbnailView.contentMode = .scaleAspectFill
|
||||
thumbnailView.clipsToBounds = true
|
||||
thumbnailView.backgroundColor = AppColor.warningBackground
|
||||
|
||||
folderIconView.tintColor = AppColor.warning
|
||||
folderIconView.contentMode = .scaleAspectFit
|
||||
|
||||
playIconView.tintColor = .white
|
||||
playIconView.contentMode = .scaleAspectFit
|
||||
playIconView.isHidden = true
|
||||
|
||||
extensionLabel.font = .app(.caption)
|
||||
extensionLabel.textColor = .white
|
||||
extensionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||
extensionLabel.layer.cornerRadius = AppRadius.xs
|
||||
extensionLabel.clipsToBounds = true
|
||||
extensionLabel.isHidden = true
|
||||
|
||||
nameLabel.font = .app(.body)
|
||||
nameLabel.textColor = AppColor.textPrimary
|
||||
nameLabel.numberOfLines = 1
|
||||
|
||||
detailLabel.font = .app(.caption)
|
||||
detailLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
detailLabel.numberOfLines = 1
|
||||
|
||||
moreButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "ellipsis"), for: .normal)
|
||||
moreButton.tintColor = AppColor.textSecondary
|
||||
moreButton.addTarget(self, action: #selector(moreTapped), for: .touchUpInside)
|
||||
|
||||
contentView.addSubview(thumbnailView)
|
||||
contentView.addSubview(folderIconView)
|
||||
contentView.addSubview(playIconView)
|
||||
contentView.addSubview(extensionLabel)
|
||||
contentView.addSubview(nameLabel)
|
||||
contentView.addSubview(detailLabel)
|
||||
contentView.addSubview(moreButton)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
thumbnailView.snp.makeConstraints { make in
|
||||
gridConstraints.append(make.top.leading.trailing.equalToSuperview().constraint)
|
||||
gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).multipliedBy(0.56).constraint)
|
||||
listConstraints.append(make.leading.centerY.equalToSuperview().constraint)
|
||||
listConstraints.append(make.width.height.equalTo(48).constraint)
|
||||
}
|
||||
folderIconView.snp.makeConstraints { make in
|
||||
make.center.equalTo(thumbnailView)
|
||||
make.width.height.equalTo(42)
|
||||
}
|
||||
playIconView.snp.makeConstraints { make in
|
||||
make.center.equalTo(thumbnailView)
|
||||
make.width.height.equalTo(28)
|
||||
}
|
||||
extensionLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalTo(thumbnailView).offset(AppSpacing.xs)
|
||||
}
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(AppSpacing.xs).constraint)
|
||||
gridConstraints.append(make.leading.equalToSuperview().offset(AppSpacing.sm).constraint)
|
||||
listConstraints.append(make.top.equalToSuperview().offset(AppSpacing.sm).constraint)
|
||||
listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(AppSpacing.sm).constraint)
|
||||
make.trailing.equalTo(moreButton.snp.leading).offset(-AppSpacing.xs)
|
||||
}
|
||||
detailLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameLabel.snp.bottom).offset(AppSpacing.xxs)
|
||||
make.leading.equalTo(nameLabel)
|
||||
make.trailing.equalTo(nameLabel)
|
||||
}
|
||||
moreButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.xs)
|
||||
make.centerY.equalTo(nameLabel.snp.bottom).offset(4)
|
||||
make.width.height.equalTo(32)
|
||||
}
|
||||
listConstraints.forEach { $0.deactivate() }
|
||||
}
|
||||
|
||||
private func configureThumbnail(_ file: CloudFile) {
|
||||
if file.isFolder {
|
||||
thumbnailView.image = nil
|
||||
thumbnailView.backgroundColor = AppColor.warningBackground
|
||||
folderIconView.isHidden = false
|
||||
extensionLabel.isHidden = true
|
||||
return
|
||||
}
|
||||
folderIconView.isHidden = true
|
||||
thumbnailView.backgroundColor = AppColor.inputBackground
|
||||
let preview = file.isVideo ? file.coverUrl : file.fileUrl
|
||||
if let url = URL(string: preview), !preview.isEmpty {
|
||||
let placeholder = CloudDriveAsset.image(
|
||||
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
|
||||
fallbackSystemName: file.isVideo ? "video" : "photo"
|
||||
)
|
||||
thumbnailView.kf.setImage(with: url, placeholder: placeholder)
|
||||
} else {
|
||||
thumbnailView.image = CloudDriveAsset.image(
|
||||
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
|
||||
fallbackSystemName: file.isVideo ? "video" : "photo"
|
||||
)
|
||||
thumbnailView.tintColor = AppColor.textTertiary
|
||||
}
|
||||
playIconView.isHidden = !file.isVideo
|
||||
extensionLabel.text = CloudDriveFormatter.fileExtension(file.fileUrl.isEmpty ? file.name : file.fileUrl)
|
||||
extensionLabel.isHidden = false
|
||||
}
|
||||
|
||||
@objc private func moreTapped() {
|
||||
onDetails?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 带内边距的标签。
|
||||
private final class CloudDrivePaddingLabel: UILabel {
|
||||
var insets = UIEdgeInsets(top: 3, left: 5, bottom: 3, right: 5)
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let size = super.intrinsicContentSize
|
||||
return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom)
|
||||
}
|
||||
|
||||
override func drawText(in rect: CGRect) {
|
||||
super.drawText(in: rect.inset(by: insets))
|
||||
}
|
||||
}
|
||||
62
suixinkan/UI/CloudDrive/CloudDriveFormatter.swift
Normal file
62
suixinkan/UI/CloudDrive/CloudDriveFormatter.swift
Normal file
@ -0,0 +1,62 @@
|
||||
//
|
||||
// CloudDriveFormatter.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 云盘展示格式化工具。
|
||||
enum CloudDriveFormatter {
|
||||
/// 文件类型名称。
|
||||
static func typeName(_ type: Int) -> String {
|
||||
switch type {
|
||||
case 1:
|
||||
"视频"
|
||||
case 2:
|
||||
"图片"
|
||||
case 99:
|
||||
"文件夹"
|
||||
default:
|
||||
"文件"
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件描述。
|
||||
static func fileDescription(_ file: CloudFile) -> String {
|
||||
if file.isFolder {
|
||||
return "\(file.childNum) 个项目"
|
||||
}
|
||||
return "\(typeName(file.type)) \(fileSize(file.fileSize))"
|
||||
}
|
||||
|
||||
/// 文件大小展示。
|
||||
static func fileSize(_ bytes: Int64) -> String {
|
||||
guard bytes > 0 else { return "0B" }
|
||||
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||
var value = Double(bytes)
|
||||
var index = 0
|
||||
while value >= 1024, index < units.count - 1 {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
if index == 0 {
|
||||
return "\(Int(value))\(units[index])"
|
||||
}
|
||||
return String(format: "%.1f%@", value, units[index])
|
||||
}
|
||||
|
||||
/// URL 或文件名的后缀。
|
||||
static func fileExtension(_ value: String) -> String {
|
||||
let ext = URL(string: value)?.pathExtension ?? URL(fileURLWithPath: value).pathExtension
|
||||
return ext.isEmpty ? typeName(0) : ext.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘 Android 同名图片资源访问工具。
|
||||
enum CloudDriveAsset {
|
||||
/// 返回云盘图片资源,缺失时使用系统图标兜底。
|
||||
static func image(named name: String, fallbackSystemName: String) -> UIImage? {
|
||||
UIImage(named: name)?.withRenderingMode(.alwaysOriginal) ?? UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
}
|
||||
529
suixinkan/UI/CloudDrive/CloudDriveListViewController.swift
Normal file
529
suixinkan/UI/CloudDrive/CloudDriveListViewController.swift
Normal file
@ -0,0 +1,529 @@
|
||||
//
|
||||
// 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<Int, CloudFile>!
|
||||
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<Int, CloudFile>(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<Int, CloudFile>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
//
|
||||
// CloudDrivePreviewViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 云盘文件预览页,黑底展示图片或视频。
|
||||
final class CloudDrivePreviewViewController: BaseViewController {
|
||||
private let file: CloudFile
|
||||
private let imageView = UIImageView()
|
||||
private var playerViewController: AVPlayerViewController?
|
||||
|
||||
/// 初始化预览页。
|
||||
init(file: CloudFile) {
|
||||
self.file = file
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = ""
|
||||
navigationController?.navigationBar.tintColor = .white
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = .black
|
||||
if file.isVideo {
|
||||
setupVideo()
|
||||
} else {
|
||||
setupImage()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
navigationController?.navigationBar.tintColor = AppColor.primary
|
||||
playerViewController?.player?.pause()
|
||||
}
|
||||
|
||||
private func setupImage() {
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.backgroundColor = .black
|
||||
view.addSubview(imageView)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
if let url = URL(string: file.fileUrl) {
|
||||
imageView.kf.setImage(with: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupVideo() {
|
||||
guard let url = URL(string: file.fileUrl) else { return }
|
||||
let player = AVPlayer(url: url)
|
||||
let controller = AVPlayerViewController()
|
||||
controller.player = player
|
||||
controller.view.backgroundColor = .black
|
||||
addChild(controller)
|
||||
view.addSubview(controller.view)
|
||||
controller.view.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
controller.didMove(toParent: self)
|
||||
playerViewController = controller
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
234
suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift
Normal file
234
suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift
Normal file
@ -0,0 +1,234 @@
|
||||
//
|
||||
// CloudDriveTransferViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 云盘传输管理页,对齐 Android `CloudStorageTransitScreen`。
|
||||
final class CloudDriveTransferViewController: BaseViewController {
|
||||
private enum Tab: Int {
|
||||
case upload
|
||||
case download
|
||||
}
|
||||
|
||||
private let manager: CloudTransferManager
|
||||
private let segmentedControl = UISegmentedControl(items: ["上传", "下载"])
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private var selectedTab: Tab = .upload
|
||||
|
||||
/// 初始化传输管理页。
|
||||
init(manager: CloudTransferManager = .shared) {
|
||||
self.manager = manager
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "传输管理"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
segmentedControl.selectedSegmentIndex = selectedTab.rawValue
|
||||
tableView.backgroundColor = AppColor.pageBackground
|
||||
tableView.separatorStyle = .none
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(CloudTransferCell.self, forCellReuseIdentifier: CloudTransferCell.reuseIdentifier)
|
||||
|
||||
view.addSubview(segmentedControl)
|
||||
view.addSubview(tableView)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
segmentedControl.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
|
||||
manager.onTasksChange = { [weak self] in
|
||||
Task { @MainActor in self?.tableView.reloadData() }
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleTasks: [CloudTransferTask] {
|
||||
selectedTab == .upload ? manager.uploadTasks : manager.downloadTasks
|
||||
}
|
||||
|
||||
@objc private func tabChanged() {
|
||||
selectedTab = Tab(rawValue: segmentedControl.selectedSegmentIndex) ?? .upload
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
extension CloudDriveTransferViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
visibleTasks.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
112
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: CloudTransferCell.reuseIdentifier, for: indexPath) as! CloudTransferCell
|
||||
let task = visibleTasks[indexPath.row]
|
||||
cell.apply(task: task)
|
||||
cell.onPrimaryAction = { [weak self] in
|
||||
guard let self else { return }
|
||||
if task.kind == .upload {
|
||||
task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id)
|
||||
} else {
|
||||
task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id)
|
||||
}
|
||||
}
|
||||
cell.onCancel = { [weak self] in
|
||||
guard let self else { return }
|
||||
task.kind == .upload ? self.manager.cancelUpload(id: task.id) : self.manager.cancelDownload(id: task.id)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘传输任务 Cell。
|
||||
private final class CloudTransferCell: UITableViewCell {
|
||||
static let reuseIdentifier = "CloudTransferCell"
|
||||
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
var onCancel: (() -> Void)?
|
||||
|
||||
private let cardView = UIView()
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let primaryButton = UIButton(type: .system)
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
private let percentLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
onPrimaryAction = nil
|
||||
onCancel = nil
|
||||
}
|
||||
|
||||
func apply(task: CloudTransferTask) {
|
||||
iconView.image = CloudDriveAsset.image(
|
||||
named: task.fileType == 1 ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
|
||||
fallbackSystemName: task.fileType == 1 ? "video.fill" : "photo.fill"
|
||||
)
|
||||
titleLabel.text = task.fileName
|
||||
statusLabel.text = "\(task.status.title) \(CloudDriveFormatter.fileSize(task.fileSize))"
|
||||
progressView.progress = Float(task.progress) / 100
|
||||
percentLabel.text = "\(task.progress)%"
|
||||
let isRunning = task.status == .uploading || task.status == .downloading
|
||||
primaryButton.setImage(UIImage(systemName: isRunning ? "pause.fill" : "play.fill"), for: .normal)
|
||||
primaryButton.isHidden = task.status == .completed
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppRadius.sm
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
titleLabel.font = .app(.body)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||
statusLabel.font = .app(.caption)
|
||||
statusLabel.textColor = AppColor.textTertiary
|
||||
progressView.progressTintColor = AppColor.primary
|
||||
percentLabel.font = .app(.caption)
|
||||
percentLabel.textColor = AppColor.textSecondary
|
||||
percentLabel.textAlignment = .right
|
||||
|
||||
primaryButton.tintColor = AppColor.primary
|
||||
cancelButton.tintColor = AppColor.textSecondary
|
||||
cancelButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(iconView)
|
||||
cardView.addSubview(titleLabel)
|
||||
cardView.addSubview(statusLabel)
|
||||
cardView.addSubview(primaryButton)
|
||||
cardView.addSubview(cancelButton)
|
||||
cardView.addSubview(progressView)
|
||||
cardView.addSubview(percentLabel)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(AppSpacing.xs)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.leading.top.equalToSuperview().offset(AppSpacing.sm)
|
||||
make.width.height.equalTo(48)
|
||||
}
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.trailing.top.equalToSuperview().inset(AppSpacing.xs)
|
||||
make.width.height.equalTo(36)
|
||||
}
|
||||
primaryButton.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(cancelButton.snp.leading)
|
||||
make.centerY.equalTo(cancelButton)
|
||||
make.width.height.equalTo(36)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm)
|
||||
make.top.equalTo(iconView)
|
||||
make.trailing.equalTo(primaryButton.snp.leading).offset(-AppSpacing.xs)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xxs)
|
||||
}
|
||||
progressView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconView)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||||
make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm)
|
||||
}
|
||||
percentLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(progressView.snp.bottom).offset(AppSpacing.xxs)
|
||||
make.trailing.equalTo(progressView)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func primaryTapped() {
|
||||
onPrimaryAction?()
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
onCancel?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user