同步相册云盘界面并完善相关交互
This commit is contained in:
@ -31,6 +31,7 @@ final class CloudTransferManager {
|
|||||||
private let photoSaver: any CloudPhotoSaving
|
private let photoSaver: any CloudPhotoSaving
|
||||||
private let scenicIdProvider: () -> Int
|
private let scenicIdProvider: () -> Int
|
||||||
private var runningTasks: [String: Task<Void, Never>] = [:]
|
private var runningTasks: [String: Task<Void, Never>] = [:]
|
||||||
|
private var operationGenerations: [String: Int] = [:]
|
||||||
|
|
||||||
/// 初始化传输管理器。
|
/// 初始化传输管理器。
|
||||||
init(
|
init(
|
||||||
@ -95,6 +96,7 @@ final class CloudTransferManager {
|
|||||||
func pauseUpload(id: String) {
|
func pauseUpload(id: String) {
|
||||||
runningTasks[id]?.cancel()
|
runningTasks[id]?.cancel()
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
|
invalidateOperation(id: id)
|
||||||
updateUploadTask(id: id) { task in
|
updateUploadTask(id: id) { task in
|
||||||
task.status = .paused
|
task.status = .paused
|
||||||
task.updatedAt = Date().timeIntervalSince1970
|
task.updatedAt = Date().timeIntervalSince1970
|
||||||
@ -110,6 +112,7 @@ final class CloudTransferManager {
|
|||||||
func cancelUpload(id: String) {
|
func cancelUpload(id: String) {
|
||||||
runningTasks[id]?.cancel()
|
runningTasks[id]?.cancel()
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
|
operationGenerations[id] = nil
|
||||||
if let task = uploadTasks.first(where: { $0.id == id }), !task.localPath.isEmpty {
|
if let task = uploadTasks.first(where: { $0.id == id }), !task.localPath.isEmpty {
|
||||||
try? FileManager.default.removeItem(atPath: task.localPath)
|
try? FileManager.default.removeItem(atPath: task.localPath)
|
||||||
}
|
}
|
||||||
@ -121,6 +124,7 @@ final class CloudTransferManager {
|
|||||||
func pauseDownload(id: String) {
|
func pauseDownload(id: String) {
|
||||||
runningTasks[id]?.cancel()
|
runningTasks[id]?.cancel()
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
|
invalidateOperation(id: id)
|
||||||
updateDownloadTask(id: id) { task in
|
updateDownloadTask(id: id) { task in
|
||||||
task.status = .paused
|
task.status = .paused
|
||||||
task.updatedAt = Date().timeIntervalSince1970
|
task.updatedAt = Date().timeIntervalSince1970
|
||||||
@ -136,6 +140,7 @@ final class CloudTransferManager {
|
|||||||
func cancelDownload(id: String) {
|
func cancelDownload(id: String) {
|
||||||
runningTasks[id]?.cancel()
|
runningTasks[id]?.cancel()
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
|
operationGenerations[id] = nil
|
||||||
downloadTasks.removeAll { $0.id == id }
|
downloadTasks.removeAll { $0.id == id }
|
||||||
persistAndNotify()
|
persistAndNotify()
|
||||||
}
|
}
|
||||||
@ -149,6 +154,7 @@ final class CloudTransferManager {
|
|||||||
uploadTasks[index].progress = max(uploadTasks[index].progress, 1)
|
uploadTasks[index].progress = max(uploadTasks[index].progress, 1)
|
||||||
uploadTasks[index].updatedAt = Date().timeIntervalSince1970
|
uploadTasks[index].updatedAt = Date().timeIntervalSince1970
|
||||||
let seed = uploadTasks[index]
|
let seed = uploadTasks[index]
|
||||||
|
let generation = nextOperationGeneration(id: id)
|
||||||
persistAndNotify()
|
persistAndNotify()
|
||||||
|
|
||||||
runningTasks[id] = Task { [weak self] in
|
runningTasks[id] = Task { [weak self] in
|
||||||
@ -166,24 +172,30 @@ final class CloudTransferManager {
|
|||||||
scenicId: scenicIdProvider()
|
scenicId: scenicIdProvider()
|
||||||
) { [weak self] progress in
|
) { [weak self] progress in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.updateUploadProgress(id: id, progress: progress)
|
guard let self, self.isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
|
self.updateUploadProgress(id: id, progress: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
try await api.uploadCloudFile(
|
try await api.uploadCloudFile(
|
||||||
parentFolderId: seed.parentFolderId,
|
parentFolderId: seed.parentFolderId,
|
||||||
fileUrl: uploadedURL,
|
fileUrl: uploadedURL,
|
||||||
fileName: seed.fileName
|
fileName: seed.fileName
|
||||||
)
|
)
|
||||||
|
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
try? FileManager.default.removeItem(atPath: seed.localPath)
|
try? FileManager.default.removeItem(atPath: seed.localPath)
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
|
operationGenerations[id] = nil
|
||||||
uploadTasks.removeAll { $0.id == id }
|
uploadTasks.removeAll { $0.id == id }
|
||||||
persistAndNotify()
|
persistAndNotify()
|
||||||
onUploadCompleted?(seed.parentFolderId)
|
onUploadCompleted?(seed.parentFolderId)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
|
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
markUploadPaused(id: id)
|
markUploadPaused(id: id)
|
||||||
} catch {
|
} catch {
|
||||||
|
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
markUploadFailed(id: id, message: error.localizedDescription)
|
markUploadFailed(id: id, message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
@ -199,6 +211,7 @@ final class CloudTransferManager {
|
|||||||
downloadTasks[index].progress = max(downloadTasks[index].progress, 1)
|
downloadTasks[index].progress = max(downloadTasks[index].progress, 1)
|
||||||
downloadTasks[index].updatedAt = Date().timeIntervalSince1970
|
downloadTasks[index].updatedAt = Date().timeIntervalSince1970
|
||||||
let seed = downloadTasks[index]
|
let seed = downloadTasks[index]
|
||||||
|
let generation = nextOperationGeneration(id: id)
|
||||||
persistAndNotify()
|
persistAndNotify()
|
||||||
|
|
||||||
runningTasks[id] = Task { [weak self] in
|
runningTasks[id] = Task { [weak self] in
|
||||||
@ -209,22 +222,28 @@ final class CloudTransferManager {
|
|||||||
fileName: seed.fileName
|
fileName: seed.fileName
|
||||||
) { [weak self] progress in
|
) { [weak self] progress in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.updateDownloadProgress(id: id, progress: progress)
|
guard let self, self.isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
|
self.updateDownloadProgress(id: id, progress: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
try await photoSaver.saveToPhotoLibrary(fileURL: localURL, fileType: seed.fileType)
|
try await photoSaver.saveToPhotoLibrary(fileURL: localURL, fileType: seed.fileType)
|
||||||
|
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
try? FileManager.default.removeItem(at: localURL)
|
try? FileManager.default.removeItem(at: localURL)
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
|
operationGenerations[id] = nil
|
||||||
updateDownloadTask(id: id) { task in
|
updateDownloadTask(id: id) { task in
|
||||||
task.status = .completed
|
task.status = .completed
|
||||||
task.progress = 100
|
task.progress = 100
|
||||||
task.updatedAt = Date().timeIntervalSince1970
|
task.updatedAt = Date().timeIntervalSince1970
|
||||||
}
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
|
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
markDownloadPaused(id: id)
|
markDownloadPaused(id: id)
|
||||||
} catch {
|
} catch {
|
||||||
|
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||||||
runningTasks[id] = nil
|
runningTasks[id] = nil
|
||||||
markDownloadFailed(id: id, message: error.localizedDescription)
|
markDownloadFailed(id: id, message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
@ -291,6 +310,20 @@ final class CloudTransferManager {
|
|||||||
store.saveTasks(uploadTasks + downloadTasks)
|
store.saveTasks(uploadTasks + downloadTasks)
|
||||||
onTasksChange?()
|
onTasksChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func nextOperationGeneration(id: String) -> Int {
|
||||||
|
let generation = (operationGenerations[id] ?? 0) + 1
|
||||||
|
operationGenerations[id] = generation
|
||||||
|
return generation
|
||||||
|
}
|
||||||
|
|
||||||
|
private func invalidateOperation(id: String) {
|
||||||
|
operationGenerations[id] = (operationGenerations[id] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCurrentOperation(id: String, generation: Int) -> Bool {
|
||||||
|
operationGenerations[id] == generation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Comparable {
|
private extension Comparable {
|
||||||
|
|||||||
@ -34,11 +34,14 @@ enum CloudDriveFilterType: Int, CaseIterable, Sendable {
|
|||||||
case image = 2
|
case image = 2
|
||||||
case folder = 99
|
case folder = 99
|
||||||
|
|
||||||
/// Android 对齐文案。
|
/// Android 下拉菜单顺序。
|
||||||
var title: String {
|
static let androidMenuOrder: [CloudDriveFilterType] = [.all, .image, .video, .folder]
|
||||||
|
|
||||||
|
/// Android 下拉菜单文案。
|
||||||
|
var menuTitle: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
"全部类型"
|
"全部"
|
||||||
case .video:
|
case .video:
|
||||||
"视频"
|
"视频"
|
||||||
case .image:
|
case .image:
|
||||||
@ -47,6 +50,20 @@ enum CloudDriveFilterType: Int, CaseIterable, Sendable {
|
|||||||
"文件夹"
|
"文件夹"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android 筛选按钮当前值文案。
|
||||||
|
var displayTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .all:
|
||||||
|
"全部"
|
||||||
|
case .video:
|
||||||
|
"视频"
|
||||||
|
case .image:
|
||||||
|
"文件"
|
||||||
|
case .folder:
|
||||||
|
"文件夹"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CloudDriveCacheKey: Hashable {
|
private struct CloudDriveCacheKey: Hashable {
|
||||||
|
|||||||
@ -20,6 +20,10 @@
|
|||||||
<string>weixin</string>
|
<string>weixin</string>
|
||||||
<string>weixinULAPI</string>
|
<string>weixinULAPI</string>
|
||||||
<string>weixinURLParamsAPI</string>
|
<string>weixinURLParamsAPI</string>
|
||||||
|
<string>iosamap</string>
|
||||||
|
<string>baidumap</string>
|
||||||
|
<string>qqmap</string>
|
||||||
|
<string>comgooglemaps</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@ -6,8 +6,14 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 云盘底部操作弹层,用于同步 Android 云盘上传与文件操作菜单。
|
/// 云盘底部操作弹层,严格同步 Android 上传与文件操作菜单。
|
||||||
final class CloudDriveActionSheetViewController: UIViewController {
|
final class CloudDriveActionSheetViewController: UIViewController {
|
||||||
|
/// 云盘弹层内容布局。
|
||||||
|
enum Layout {
|
||||||
|
case upload
|
||||||
|
case controls
|
||||||
|
}
|
||||||
|
|
||||||
/// 云盘底部弹层操作项。
|
/// 云盘底部弹层操作项。
|
||||||
struct Item {
|
struct Item {
|
||||||
/// 操作文案。
|
/// 操作文案。
|
||||||
@ -20,19 +26,20 @@ final class CloudDriveActionSheetViewController: UIViewController {
|
|||||||
let handler: () -> Void
|
let handler: () -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
private let sheetTitle: String?
|
private let sheetTitle: String
|
||||||
|
private let layout: Layout
|
||||||
private let items: [Item]
|
private let items: [Item]
|
||||||
private let dimView = UIView()
|
private let dimView = UIView()
|
||||||
private let panelView = UIView()
|
private let panelView = UIView()
|
||||||
private let stackView = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
|
|
||||||
/// 初始化云盘底部弹层。
|
/// 初始化 Android 风格云盘底部弹层。
|
||||||
init(title: String? = nil, items: [Item]) {
|
init(title: String, layout: Layout, items: [Item]) {
|
||||||
self.sheetTitle = title
|
sheetTitle = title
|
||||||
|
self.layout = layout
|
||||||
self.items = items
|
self.items = items
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .overFullScreen
|
modalPresentationStyle = .overFullScreen
|
||||||
modalTransitionStyle = .crossDissolve
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@ -46,92 +53,607 @@ final class CloudDriveActionSheetViewController: UIViewController {
|
|||||||
setupConstraints()
|
setupConstraints()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
dimView.alpha = 0
|
||||||
|
panelView.transform = CGAffineTransform(translationX: 0, y: 420)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
UIView.animate(withDuration: 0.24, delay: 0, options: [.curveEaseOut]) {
|
||||||
|
self.dimView.alpha = 1
|
||||||
|
self.panelView.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
|
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
|
||||||
|
|
||||||
panelView.backgroundColor = .white
|
panelView.backgroundColor = .white
|
||||||
panelView.layer.cornerRadius = 18
|
panelView.layer.cornerRadius = 16
|
||||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
panelView.clipsToBounds = true
|
panelView.clipsToBounds = true
|
||||||
|
|
||||||
stackView.axis = .vertical
|
contentStack.axis = .vertical
|
||||||
stackView.spacing = 0
|
contentStack.spacing = 0
|
||||||
|
|
||||||
view.addSubview(dimView)
|
view.addSubview(dimView)
|
||||||
view.addSubview(panelView)
|
view.addSubview(panelView)
|
||||||
panelView.addSubview(stackView)
|
panelView.addSubview(contentStack)
|
||||||
|
|
||||||
if let sheetTitle {
|
let titleContainer = UIView()
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.text = sheetTitle
|
titleLabel.text = sheetTitle
|
||||||
titleLabel.font = .app(.bodyMedium)
|
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||||
titleLabel.textColor = AppColor.textPrimary
|
titleLabel.textColor = .black
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .left
|
||||||
|
titleContainer.addSubview(titleLabel)
|
||||||
|
titleContainer.snp.makeConstraints { make in make.height.equalTo(52) }
|
||||||
titleLabel.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.height.equalTo(54)
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
}
|
}
|
||||||
stackView.addArrangedSubview(titleLabel)
|
contentStack.addArrangedSubview(titleContainer)
|
||||||
|
|
||||||
|
let titleDivider = UIView()
|
||||||
|
titleDivider.backgroundColor = UIColor(hex: 0xEEEEEE)
|
||||||
|
titleDivider.snp.makeConstraints { make in make.height.equalTo(0.5) }
|
||||||
|
contentStack.addArrangedSubview(titleDivider)
|
||||||
|
|
||||||
|
switch layout {
|
||||||
|
case .upload:
|
||||||
|
contentStack.addArrangedSubview(makeUploadContent())
|
||||||
|
case .controls:
|
||||||
|
contentStack.addArrangedSubview(makeControlsContent())
|
||||||
}
|
}
|
||||||
|
|
||||||
for item in items {
|
let divider = UIView()
|
||||||
stackView.addArrangedSubview(makeItemButton(item))
|
divider.backgroundColor = UIColor(hex: 0xEEEEEE)
|
||||||
}
|
divider.snp.makeConstraints { make in make.height.equalTo(0.5) }
|
||||||
|
contentStack.addArrangedSubview(divider)
|
||||||
let spacer = UIView()
|
contentStack.addArrangedSubview(makeCancelArea())
|
||||||
spacer.backgroundColor = AppColor.pageBackground
|
let bottomSpacer = UIView()
|
||||||
spacer.snp.makeConstraints { make in
|
bottomSpacer.snp.makeConstraints { make in make.height.equalTo(12) }
|
||||||
make.height.equalTo(8)
|
contentStack.addArrangedSubview(bottomSpacer)
|
||||||
}
|
|
||||||
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() {
|
private func setupConstraints() {
|
||||||
dimView.snp.makeConstraints { make in
|
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
make.edges.equalToSuperview()
|
|
||||||
}
|
|
||||||
panelView.snp.makeConstraints { make in
|
panelView.snp.makeConstraints { make in
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
make.top.greaterThanOrEqualTo(view.safeAreaLayoutGuide).offset(20)
|
||||||
}
|
}
|
||||||
stackView.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalToSuperview()
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeItemButton(_ item: Item) -> UIButton {
|
private func makeUploadContent() -> UIView {
|
||||||
|
let container = UIView()
|
||||||
|
let stack = UIStackView()
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
container.addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
|
||||||
|
|
||||||
|
items.enumerated().forEach { index, item in
|
||||||
|
let row = UIView()
|
||||||
|
row.layer.cornerRadius = 8
|
||||||
|
row.layer.borderWidth = 1
|
||||||
|
row.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
|
||||||
|
|
||||||
|
let iconView = UIImageView(image: item.image?.withRenderingMode(.alwaysOriginal))
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = item.title
|
||||||
|
label.font = .systemFont(ofSize: 14, weight: .bold)
|
||||||
|
label.textColor = .black
|
||||||
|
let button = UIButton(type: .custom)
|
||||||
|
button.tag = index
|
||||||
|
button.accessibilityLabel = item.title
|
||||||
|
button.addTarget(self, action: #selector(itemTapped(_:)), for: .touchUpInside)
|
||||||
|
|
||||||
|
row.addSubview(iconView)
|
||||||
|
row.addSubview(label)
|
||||||
|
row.addSubview(button)
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.width.height.equalTo(32)
|
||||||
|
}
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(iconView.snp.trailing).offset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
button.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
|
row.snp.makeConstraints { make in make.height.equalTo(56) }
|
||||||
|
stack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeControlsContent() -> UIView {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.distribution = .fillEqually
|
||||||
|
|
||||||
|
items.enumerated().forEach { index, item in
|
||||||
|
let container = UIView()
|
||||||
|
let iconView = UIImageView(image: item.image?.withRenderingMode(.alwaysOriginal))
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = item.title
|
||||||
|
label.font = .systemFont(ofSize: 14)
|
||||||
|
label.textColor = UIColor(hex: 0x4B5563)
|
||||||
|
label.textAlignment = .center
|
||||||
|
let button = UIButton(type: .custom)
|
||||||
|
button.tag = index
|
||||||
|
button.accessibilityLabel = item.title
|
||||||
|
button.addTarget(self, action: #selector(itemTapped(_:)), for: .touchUpInside)
|
||||||
|
|
||||||
|
container.addSubview(iconView)
|
||||||
|
container.addSubview(label)
|
||||||
|
container.addSubview(button)
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(28)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.width.height.equalTo(24)
|
||||||
|
}
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(iconView.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(4)
|
||||||
|
}
|
||||||
|
button.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
|
container.snp.makeConstraints { make in make.height.equalTo(105) }
|
||||||
|
row.addArrangedSubview(container)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCancelArea() -> UIView {
|
||||||
|
let container = UIView()
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.setTitle(item.title, for: .normal)
|
button.setTitle("取消", for: .normal)
|
||||||
button.setImage(item.image, for: .normal)
|
button.setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
|
||||||
button.tintColor = item.isDestructive ? AppColor.danger : AppColor.textPrimary
|
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
button.setTitleColor(item.isDestructive ? AppColor.danger : AppColor.textPrimary, for: .normal)
|
button.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
button.titleLabel?.font = .app(.body)
|
button.layer.cornerRadius = 12
|
||||||
button.contentHorizontalAlignment = .left
|
button.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.xl, bottom: 0, right: AppSpacing.xl)
|
container.addSubview(button)
|
||||||
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
|
button.snp.makeConstraints { make in
|
||||||
make.height.equalTo(54)
|
make.edges.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(48)
|
||||||
}
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private func close(completion: (() -> Void)? = nil) {
|
||||||
|
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) {
|
||||||
|
self.dimView.alpha = 0
|
||||||
|
self.panelView.transform = CGAffineTransform(translationX: 0, y: self.panelView.bounds.height + 40)
|
||||||
|
} completion: { _ in
|
||||||
|
self.dismiss(animated: false, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func itemTapped(_ sender: UIButton) {
|
||||||
|
guard items.indices.contains(sender.tag) else { return }
|
||||||
|
let handler = items[sender.tag].handler
|
||||||
|
close(completion: handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘居中对话框基类,提供 Android 风格遮罩、圆角和转场。
|
||||||
|
class CloudDriveOverlayDialogViewController: UIViewController {
|
||||||
|
let dimView = UIView()
|
||||||
|
let cardView = UIView()
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
modalPresentationStyle = .overFullScreen
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 12
|
||||||
|
cardView.clipsToBounds = true
|
||||||
|
view.addSubview(dimView)
|
||||||
|
view.addSubview(cardView)
|
||||||
|
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
|
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelOverlay)))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
dimView.alpha = 0
|
||||||
|
cardView.alpha = 0
|
||||||
|
cardView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
self.dimView.alpha = 1
|
||||||
|
self.cardView.alpha = 1
|
||||||
|
self.cardView.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissOverlay(completion: (() -> Void)? = nil) {
|
||||||
|
UIView.animate(withDuration: 0.16) {
|
||||||
|
self.dimView.alpha = 0
|
||||||
|
self.cardView.alpha = 0
|
||||||
|
self.cardView.transform = CGAffineTransform(scaleX: 0.96, y: 0.96)
|
||||||
|
} completion: { _ in
|
||||||
|
self.dismiss(animated: false, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelOverlay() {
|
||||||
|
dismissOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android `WeDialog` 对齐的云盘删除确认对话框。
|
||||||
|
final class CloudDriveConfirmationDialogViewController: CloudDriveOverlayDialogViewController {
|
||||||
|
private let dialogTitle: String
|
||||||
|
private let message: String
|
||||||
|
private let onConfirm: () -> Void
|
||||||
|
|
||||||
|
/// 创建删除确认对话框。
|
||||||
|
init(title: String, message: String, onConfirm: @escaping () -> Void) {
|
||||||
|
dialogTitle = title
|
||||||
|
self.message = message
|
||||||
|
self.onConfirm = onConfirm
|
||||||
|
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()
|
||||||
|
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = dialogTitle
|
||||||
|
titleLabel.font = .systemFont(ofSize: 17, weight: .bold)
|
||||||
|
titleLabel.textColor = .black
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
let messageLabel = UILabel()
|
||||||
|
messageLabel.text = message
|
||||||
|
messageLabel.font = .systemFont(ofSize: 17)
|
||||||
|
messageLabel.textColor = .black
|
||||||
|
messageLabel.textAlignment = .center
|
||||||
|
messageLabel.numberOfLines = 0
|
||||||
|
let divider = UIView()
|
||||||
|
divider.backgroundColor = UIColor(hex: 0xD1D5DB)
|
||||||
|
let verticalDivider = UIView()
|
||||||
|
verticalDivider.backgroundColor = UIColor(hex: 0xD1D5DB)
|
||||||
|
let cancelButton = makeDialogButton(title: "取消", color: .black, action: #selector(cancelTapped))
|
||||||
|
let confirmButton = makeDialogButton(title: "确定", color: AppColor.primary, action: #selector(confirmTapped))
|
||||||
|
|
||||||
|
[titleLabel, messageLabel, divider, cancelButton, verticalDivider, confirmButton].forEach(cardView.addSubview)
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.width.equalToSuperview().multipliedBy(0.8)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(32)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
messageLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(16)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
divider.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(messageLabel.snp.bottom).offset(32)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(0.5)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(divider.snp.bottom)
|
||||||
|
make.leading.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(56)
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.width.equalTo(cancelButton)
|
||||||
|
make.leading.equalTo(cancelButton.snp.trailing)
|
||||||
|
make.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
verticalDivider.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(divider.snp.bottom)
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.width.equalTo(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDialogButton(title: String, color: UIColor, action: Selector) -> UIButton {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setTitleColor(color, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 17, weight: .bold)
|
||||||
|
button.addTarget(self, action: action, for: .touchUpInside)
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func cancelTapped() {
|
@objc private func cancelTapped() {
|
||||||
dismiss(animated: true)
|
dismissOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
dismissOverlay(completion: onConfirm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android `AddFolderDialog` 对齐的新建文件夹输入对话框。
|
||||||
|
final class CloudDriveTextInputDialogViewController: CloudDriveOverlayDialogViewController {
|
||||||
|
private let dialogTitle: String
|
||||||
|
private let placeholder: String
|
||||||
|
private let onConfirm: (String) -> Void
|
||||||
|
private let textField = UITextField()
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
/// 创建文本输入对话框。
|
||||||
|
init(title: String, placeholder: String, onConfirm: @escaping (String) -> Void) {
|
||||||
|
dialogTitle = title
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.onConfirm = onConfirm
|
||||||
|
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()
|
||||||
|
cardView.layer.cornerRadius = 16
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = dialogTitle
|
||||||
|
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
titleLabel.textColor = .black
|
||||||
|
let divider = UIView()
|
||||||
|
divider.backgroundColor = UIColor(hex: 0xEEEEEE)
|
||||||
|
|
||||||
|
textField.placeholder = placeholder
|
||||||
|
textField.font = .systemFont(ofSize: 14)
|
||||||
|
textField.textColor = UIColor(hex: 0x333333)
|
||||||
|
textField.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
|
textField.layer.cornerRadius = 8
|
||||||
|
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||||||
|
textField.leftViewMode = .always
|
||||||
|
textField.clearButtonMode = .whileEditing
|
||||||
|
textField.returnKeyType = .done
|
||||||
|
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
|
||||||
|
textField.addTarget(self, action: #selector(confirmTapped), for: .editingDidEndOnExit)
|
||||||
|
|
||||||
|
let cancelButton = UIButton(type: .system)
|
||||||
|
cancelButton.setTitle("取消", for: .normal)
|
||||||
|
cancelButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
cancelButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
cancelButton.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||||
|
cancelButton.layer.cornerRadius = 8
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
confirmButton.setTitle("确定", for: .normal)
|
||||||
|
confirmButton.setTitleColor(.white, for: .normal)
|
||||||
|
confirmButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
confirmButton.layer.cornerRadius = 8
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
confirmButton.isEnabled = false
|
||||||
|
updateConfirmAppearance()
|
||||||
|
|
||||||
|
[titleLabel, divider, textField, cancelButton, confirmButton].forEach(cardView.addSubview)
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(28)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(52)
|
||||||
|
}
|
||||||
|
divider.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(1)
|
||||||
|
}
|
||||||
|
textField.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(divider.snp.bottom).offset(16)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(textField.snp.bottom).offset(20)
|
||||||
|
make.trailing.bottom.equalToSuperview().inset(16)
|
||||||
|
make.width.equalTo(72)
|
||||||
|
make.height.equalTo(38)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalTo(confirmButton.snp.leading).offset(-8)
|
||||||
|
make.centerY.width.height.equalTo(confirmButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(keyboardFrameChanged(_:)),
|
||||||
|
name: UIResponder.keyboardWillChangeFrameNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateConfirmAppearance() {
|
||||||
|
confirmButton.backgroundColor = confirmButton.isEnabled ? AppColor.primary : AppColor.buttonDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func textChanged() {
|
||||||
|
confirmButton.isEnabled = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
updateConfirmAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
view.endEditing(true)
|
||||||
|
dismissOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
let value = (textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.isEmpty else { return }
|
||||||
|
view.endEditing(true)
|
||||||
|
dismissOverlay { [onConfirm] in onConfirm(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardFrameChanged(_ notification: Notification) {
|
||||||
|
guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
||||||
|
let frameInView = view.convert(frame, from: nil)
|
||||||
|
let overlap = max(0, cardView.frame.maxY + 16 - frameInView.minY)
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
self.cardView.transform = CGAffineTransform(translationX: 0, y: -overlap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android `MoveFileDialog` 对齐的云盘移动目标对话框。
|
||||||
|
final class CloudDriveMoveDialogViewController: CloudDriveOverlayDialogViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
private let folders: [CloudFile]
|
||||||
|
private let onConfirm: (Int) -> Void
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private var selectedID: Int
|
||||||
|
|
||||||
|
/// 创建移动目标选择对话框。
|
||||||
|
init(folders: [CloudFile], onConfirm: @escaping (Int) -> Void) {
|
||||||
|
self.folders = folders
|
||||||
|
self.onConfirm = onConfirm
|
||||||
|
selectedID = folders.first?.id ?? 0
|
||||||
|
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()
|
||||||
|
cardView.layer.cornerRadius = 28
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = "移动到"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x333333)
|
||||||
|
let messageLabel = UILabel()
|
||||||
|
messageLabel.text = "请选择目标文件夹"
|
||||||
|
messageLabel.font = .systemFont(ofSize: 14)
|
||||||
|
messageLabel.textColor = UIColor(hex: 0x666666)
|
||||||
|
|
||||||
|
tableView.backgroundColor = .white
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.rowHeight = 44
|
||||||
|
tableView.dataSource = self
|
||||||
|
tableView.delegate = self
|
||||||
|
|
||||||
|
let cancelButton = makeTextButton(title: "取消", color: UIColor(hex: 0x666666), action: #selector(cancelTapped))
|
||||||
|
let confirmButton = makeTextButton(title: "确定", color: AppColor.primary, action: #selector(confirmTapped))
|
||||||
|
|
||||||
|
[titleLabel, messageLabel, tableView, cancelButton, confirmButton].forEach(cardView.addSubview)
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(28)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(24)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
messageLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalTo(titleLabel)
|
||||||
|
}
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(messageLabel.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(min(CGFloat(max(folders.count, 1)) * 44, UIScreen.main.bounds.height * 0.6))
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(tableView.snp.bottom).offset(8)
|
||||||
|
make.trailing.bottom.equalToSuperview().inset(12)
|
||||||
|
make.width.equalTo(64)
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalTo(confirmButton.snp.leading)
|
||||||
|
make.centerY.width.height.equalTo(confirmButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
folders.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let reuseIdentifier = "CloudDriveMoveFolderCell"
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier)
|
||||||
|
?? UITableViewCell(style: .default, reuseIdentifier: reuseIdentifier)
|
||||||
|
configure(cell, folder: folders[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure(_ cell: UITableViewCell, folder: CloudFile) {
|
||||||
|
cell.textLabel?.text = folder.name
|
||||||
|
cell.textLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
cell.textLabel?.textColor = UIColor(hex: 0x333333)
|
||||||
|
cell.imageView?.image = UIImage(
|
||||||
|
systemName: selectedID == folder.id ? "circle.inset.filled" : "circle",
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20)
|
||||||
|
)
|
||||||
|
cell.imageView?.tintColor = AppColor.primary
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
let previousID = selectedID
|
||||||
|
selectedID = folders[indexPath.row].id
|
||||||
|
if let previousIndex = folders.firstIndex(where: { $0.id == previousID }),
|
||||||
|
let previousCell = tableView.cellForRow(at: IndexPath(row: previousIndex, section: 0)) {
|
||||||
|
configure(previousCell, folder: folders[previousIndex])
|
||||||
|
}
|
||||||
|
if let selectedCell = tableView.cellForRow(at: indexPath) {
|
||||||
|
configure(selectedCell, folder: folders[indexPath.row])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTextButton(title: String, color: UIColor, action: Selector) -> UIButton {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setTitleColor(color, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
button.addTarget(self, action: action, for: .touchUpInside)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
dismissOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
let selectedID = selectedID
|
||||||
|
dismissOverlay { [onConfirm] in onConfirm(selectedID) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,18 +6,15 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 云盘文件详情底部表单,用于查看文件信息并修改名称。
|
/// 云盘文件详情底部表单,严格对齐 Android `CloudStorageFileDetailBottomModal`。
|
||||||
final class CloudDriveDetailSheetViewController: UIViewController {
|
final class CloudDriveDetailSheetViewController: UIViewController {
|
||||||
private let file: CloudFile
|
private let file: CloudFile
|
||||||
private let onSave: (String) -> Void
|
private let onSave: (String) -> Void
|
||||||
private let dimView = UIView()
|
private let dimView = UIView()
|
||||||
private let panelView = UIView()
|
private let panelView = UIView()
|
||||||
private let titleLabel = UILabel()
|
private let scrollView = UIScrollView()
|
||||||
private let nameTitleLabel = UILabel()
|
private let contentView = UIView()
|
||||||
private let nameField = UITextField()
|
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) {
|
init(file: CloudFile, onSave: @escaping (String) -> Void) {
|
||||||
@ -25,7 +22,6 @@ final class CloudDriveDetailSheetViewController: UIViewController {
|
|||||||
self.onSave = onSave
|
self.onSave = onSave
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .overFullScreen
|
modalPresentationStyle = .overFullScreen
|
||||||
modalTransitionStyle = .crossDissolve
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@ -37,6 +33,25 @@ final class CloudDriveDetailSheetViewController: UIViewController {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setupUI()
|
setupUI()
|
||||||
setupConstraints()
|
setupConstraints()
|
||||||
|
registerKeyboardNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
dimView.alpha = 0
|
||||||
|
panelView.transform = CGAffineTransform(translationX: 0, y: 620)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
UIView.animate(withDuration: 0.24, delay: 0, options: [.curveEaseOut]) {
|
||||||
|
self.dimView.alpha = 1
|
||||||
|
self.panelView.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
@ -45,99 +60,241 @@ final class CloudDriveDetailSheetViewController: UIViewController {
|
|||||||
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
|
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
|
||||||
|
|
||||||
panelView.backgroundColor = .white
|
panelView.backgroundColor = .white
|
||||||
panelView.layer.cornerRadius = 18
|
panelView.layer.cornerRadius = 28
|
||||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
panelView.clipsToBounds = true
|
panelView.clipsToBounds = true
|
||||||
|
scrollView.alwaysBounceVertical = false
|
||||||
titleLabel.text = "详情"
|
scrollView.keyboardDismissMode = .interactive
|
||||||
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(dimView)
|
||||||
view.addSubview(panelView)
|
view.addSubview(panelView)
|
||||||
[titleLabel, nameTitleLabel, nameField, infoLabel, cancelButton, saveButton].forEach(panelView.addSubview)
|
panelView.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(contentView)
|
||||||
|
|
||||||
|
let dragHandle = UIView()
|
||||||
|
dragHandle.backgroundColor = UIColor(hex: 0x79747E)
|
||||||
|
dragHandle.layer.cornerRadius = 2
|
||||||
|
|
||||||
|
let typeIcon = UIImageView(image: fileTypeIcon())
|
||||||
|
typeIcon.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
let fileNameLabel = UILabel()
|
||||||
|
fileNameLabel.text = file.name
|
||||||
|
fileNameLabel.font = .systemFont(ofSize: 16)
|
||||||
|
fileNameLabel.textColor = UIColor(hex: 0x333333)
|
||||||
|
fileNameLabel.numberOfLines = 1
|
||||||
|
fileNameLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
|
let metaLabel = UILabel()
|
||||||
|
metaLabel.text = "\(file.createdAt) | \(CloudDriveFormatter.fileSize(file.fileSize))"
|
||||||
|
metaLabel.font = .systemFont(ofSize: 12)
|
||||||
|
metaLabel.textColor = UIColor(hex: 0x999999)
|
||||||
|
metaLabel.numberOfLines = 1
|
||||||
|
|
||||||
|
let closeButton = UIButton(type: .system)
|
||||||
|
closeButton.setImage(
|
||||||
|
UIImage(systemName: "xmark")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)),
|
||||||
|
for: .normal
|
||||||
|
)
|
||||||
|
closeButton.tintColor = UIColor(hex: 0x333333)
|
||||||
|
closeButton.accessibilityLabel = "关闭"
|
||||||
|
closeButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
let nameTitleLabel = makeFieldTitle("文件名称")
|
||||||
|
nameField.text = file.name
|
||||||
|
nameField.placeholder = file.name
|
||||||
|
nameField.font = .systemFont(ofSize: 16)
|
||||||
|
nameField.textColor = UIColor(hex: 0x333333)
|
||||||
|
nameField.clearButtonMode = .whileEditing
|
||||||
|
nameField.returnKeyType = .done
|
||||||
|
nameField.delegate = self
|
||||||
|
nameField.backgroundColor = .white
|
||||||
|
nameField.layer.cornerRadius = 4
|
||||||
|
nameField.layer.borderWidth = 1
|
||||||
|
nameField.layer.borderColor = UIColor(hex: 0x9CA3AF).cgColor
|
||||||
|
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||||||
|
nameField.leftViewMode = .always
|
||||||
|
|
||||||
|
let infoStack = UIStackView()
|
||||||
|
infoStack.axis = .vertical
|
||||||
|
infoStack.spacing = 0
|
||||||
|
infoStack.addArrangedSubview(makeInfoRow(label: "文件类型", value: detailTypeText()))
|
||||||
|
if file.isFolder {
|
||||||
|
infoStack.addArrangedSubview(makeInfoRow(label: "项目数量", value: String(file.childNum)))
|
||||||
|
} else {
|
||||||
|
infoStack.addArrangedSubview(makeInfoRow(label: "文件大小", value: CloudDriveFormatter.fileSize(file.fileSize)))
|
||||||
|
}
|
||||||
|
infoStack.addArrangedSubview(makeInfoRow(label: "创建时间", value: file.createdAt))
|
||||||
|
infoStack.addArrangedSubview(makeInfoRow(label: "修改时间", value: file.updatedAt))
|
||||||
|
|
||||||
|
let saveButton = UIButton(type: .system)
|
||||||
|
saveButton.setTitle("保存", for: .normal)
|
||||||
|
saveButton.setTitleColor(.white, for: .normal)
|
||||||
|
saveButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
saveButton.backgroundColor = AppColor.primary
|
||||||
|
saveButton.layer.cornerRadius = 10
|
||||||
|
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
[dragHandle, typeIcon, fileNameLabel, metaLabel, closeButton, nameTitleLabel, nameField, infoStack, saveButton]
|
||||||
|
.forEach(contentView.addSubview)
|
||||||
|
|
||||||
|
dragHandle.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(15)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.width.equalTo(40)
|
||||||
|
make.height.equalTo(4)
|
||||||
|
}
|
||||||
|
typeIcon.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(dragHandle.snp.bottom).offset(24)
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.width.height.equalTo(40)
|
||||||
|
}
|
||||||
|
closeButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(typeIcon)
|
||||||
|
make.trailing.equalToSuperview().inset(4)
|
||||||
|
make.width.height.equalTo(44)
|
||||||
|
}
|
||||||
|
fileNameLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(typeIcon)
|
||||||
|
make.leading.equalTo(typeIcon.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalTo(closeButton.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
metaLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(fileNameLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.trailing.equalTo(fileNameLabel)
|
||||||
|
}
|
||||||
|
nameTitleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(typeIcon.snp.bottom).offset(16)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
nameField.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(nameTitleLabel.snp.bottom).offset(6)
|
||||||
|
make.leading.trailing.equalTo(nameTitleLabel)
|
||||||
|
make.height.equalTo(52)
|
||||||
|
}
|
||||||
|
infoStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(nameField.snp.bottom).offset(10)
|
||||||
|
make.leading.trailing.equalTo(nameField)
|
||||||
|
}
|
||||||
|
saveButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(infoStack.snp.bottom).offset(20)
|
||||||
|
make.leading.trailing.equalTo(nameField)
|
||||||
|
make.height.equalTo(44)
|
||||||
|
make.bottom.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupConstraints() {
|
private func setupConstraints() {
|
||||||
dimView.snp.makeConstraints { make in
|
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
make.edges.equalToSuperview()
|
|
||||||
}
|
|
||||||
panelView.snp.makeConstraints { make in
|
panelView.snp.makeConstraints { make in
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
make.top.greaterThanOrEqualTo(view.safeAreaLayoutGuide).offset(16)
|
||||||
|
make.height.equalTo(590).priority(.high)
|
||||||
}
|
}
|
||||||
titleLabel.snp.makeConstraints { make in
|
scrollView.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalToSuperview()
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.height.equalTo(54)
|
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
}
|
}
|
||||||
nameTitleLabel.snp.makeConstraints { make in
|
contentView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
make.edges.equalTo(scrollView.contentLayoutGuide)
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
make.width.equalTo(scrollView.frameLayoutGuide)
|
||||||
}
|
}
|
||||||
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)
|
private func makeFieldTitle(_ text: String) -> UILabel {
|
||||||
make.leading.trailing.equalTo(nameTitleLabel)
|
let label = UILabel()
|
||||||
|
label.text = text
|
||||||
|
label.font = .systemFont(ofSize: 13)
|
||||||
|
label.textColor = UIColor(hex: 0x7B8EAA)
|
||||||
|
return label
|
||||||
}
|
}
|
||||||
cancelButton.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(infoLabel.snp.bottom).offset(AppSpacing.lg)
|
private func makeInfoRow(label: String, value: String) -> UIView {
|
||||||
make.leading.equalToSuperview().offset(AppSpacing.md)
|
let container = UIView()
|
||||||
make.height.equalTo(44)
|
let labelView = makeFieldTitle(label)
|
||||||
make.width.equalTo(saveButton)
|
let valueContainer = UIView()
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
|
valueContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
|
valueContainer.layer.cornerRadius = 6
|
||||||
|
let valueLabel = UILabel()
|
||||||
|
valueLabel.text = value
|
||||||
|
valueLabel.font = .systemFont(ofSize: 14)
|
||||||
|
valueLabel.textColor = UIColor(hex: 0x333333)
|
||||||
|
valueLabel.numberOfLines = 1
|
||||||
|
valueLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
|
container.addSubview(labelView)
|
||||||
|
container.addSubview(valueContainer)
|
||||||
|
valueContainer.addSubview(valueLabel)
|
||||||
|
labelView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
}
|
}
|
||||||
saveButton.snp.makeConstraints { make in
|
valueContainer.snp.makeConstraints { make in
|
||||||
make.top.bottom.width.equalTo(cancelButton)
|
make.top.equalTo(labelView.snp.bottom).offset(4)
|
||||||
make.leading.equalTo(cancelButton.snp.trailing).offset(AppSpacing.md)
|
make.leading.trailing.equalToSuperview()
|
||||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
make.height.equalTo(36)
|
||||||
|
make.bottom.equalToSuperview().inset(6)
|
||||||
|
}
|
||||||
|
valueLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileTypeIcon() -> UIImage? {
|
||||||
|
if file.isFolder {
|
||||||
|
return CloudDriveAsset.image(named: "icon_cloud_storage_file_type_folder", fallbackSystemName: "folder.fill")
|
||||||
|
}
|
||||||
|
if file.isVideo {
|
||||||
|
return CloudDriveAsset.image(named: "icon_cloud_storage_file_type_video", fallbackSystemName: "video.fill")
|
||||||
|
}
|
||||||
|
return CloudDriveAsset.image(named: "icon_cloud_storage_file_type_photo", fallbackSystemName: "photo.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailTypeText() -> String {
|
||||||
|
if file.isFolder { return "文件夹" }
|
||||||
|
let suffix = (file.name as NSString).pathExtension.uppercased()
|
||||||
|
return suffix.isEmpty ? "文件" : suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerKeyboardNotifications() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(keyboardFrameChanged(_:)),
|
||||||
|
name: UIResponder.keyboardWillChangeFrameNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func close(completion: (() -> Void)? = nil) {
|
||||||
|
view.endEditing(true)
|
||||||
|
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) {
|
||||||
|
self.dimView.alpha = 0
|
||||||
|
self.panelView.transform = CGAffineTransform(translationX: 0, y: self.panelView.bounds.height + 40)
|
||||||
|
} completion: { _ in
|
||||||
|
self.dismiss(animated: false, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func cancelTapped() {
|
@objc private func cancelTapped() {
|
||||||
dismiss(animated: true)
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func saveTapped() {
|
@objc private func saveTapped() {
|
||||||
let name = nameField.text ?? ""
|
let name = (nameField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
dismiss(animated: true) { [onSave] in
|
close { [onSave] in onSave(name) }
|
||||||
onSave(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardFrameChanged(_ notification: Notification) {
|
||||||
|
guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
||||||
|
let frameInView = view.convert(frame, from: nil)
|
||||||
|
let overlap = max(0, view.bounds.maxY - frameInView.minY)
|
||||||
|
scrollView.contentInset.bottom = overlap
|
||||||
|
scrollView.verticalScrollIndicatorInsets.bottom = overlap
|
||||||
|
guard overlap > 0 else { return }
|
||||||
|
let fieldFrame = nameField.convert(nameField.bounds, to: scrollView)
|
||||||
|
scrollView.scrollRectToVisible(fieldFrame.insetBy(dx: 0, dy: -16), animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,15 +7,20 @@ import Kingfisher
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 云盘文件 Cell,支持 Android 宫格与列表两种样式。
|
/// 云盘文件 Cell,严格对齐 Android 宫格与列表两种样式。
|
||||||
final class CloudDriveFileCell: UICollectionViewCell {
|
final class CloudDriveFileCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "CloudDriveFileCell"
|
static let reuseIdentifier = "CloudDriveFileCell"
|
||||||
|
|
||||||
|
/// 点击文件操作入口时回调。
|
||||||
var onDetails: (() -> Void)?
|
var onDetails: (() -> Void)?
|
||||||
|
|
||||||
private let thumbnailView = UIImageView()
|
private let thumbnailView = UIImageView()
|
||||||
private let folderIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_folder_type", fallbackSystemName: "folder.fill"))
|
private let folderIconView = UIImageView(
|
||||||
private let playIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_play", fallbackSystemName: "play.circle.fill"))
|
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 extensionLabel = CloudDrivePaddingLabel()
|
||||||
private let nameLabel = UILabel()
|
private let nameLabel = UILabel()
|
||||||
private let detailLabel = UILabel()
|
private let detailLabel = UILabel()
|
||||||
@ -47,126 +52,151 @@ final class CloudDriveFileCell: UICollectionViewCell {
|
|||||||
|
|
||||||
/// 应用云盘文件数据。
|
/// 应用云盘文件数据。
|
||||||
func apply(file: CloudFile, mode: CloudDriveDisplayMode) {
|
func apply(file: CloudFile, mode: CloudDriveDisplayMode) {
|
||||||
|
let isGrid = mode == .grid
|
||||||
|
gridConstraints.forEach { isGrid ? $0.activate() : $0.deactivate() }
|
||||||
|
listConstraints.forEach { isGrid ? $0.deactivate() : $0.activate() }
|
||||||
|
|
||||||
nameLabel.text = file.name
|
nameLabel.text = file.name
|
||||||
detailLabel.text = mode == .grid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file)
|
nameLabel.font = .systemFont(ofSize: 15, weight: isGrid ? .semibold : .regular)
|
||||||
configureThumbnail(file)
|
nameLabel.lineBreakMode = isGrid ? .byTruncatingTail : .byTruncatingMiddle
|
||||||
gridConstraints.forEach { mode == .grid ? $0.activate() : $0.deactivate() }
|
detailLabel.text = isGrid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file)
|
||||||
listConstraints.forEach { mode == .list ? $0.activate() : $0.deactivate() }
|
detailLabel.textColor = isGrid ? UIColor(hex: 0x4B5563) : UIColor(hex: 0x7B8EAA)
|
||||||
contentView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0
|
|
||||||
contentView.layer.borderWidth = mode == .grid ? 1 : 0
|
contentView.layer.cornerRadius = isGrid ? 12 : 0
|
||||||
contentView.backgroundColor = mode == .grid ? .white : .clear
|
contentView.layer.borderWidth = isGrid ? 1 : 0
|
||||||
thumbnailView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0
|
contentView.backgroundColor = isGrid ? .white : .clear
|
||||||
nameLabel.textAlignment = mode == .grid ? .left : .left
|
thumbnailView.layer.cornerRadius = isGrid ? 12 : 0
|
||||||
|
thumbnailView.clipsToBounds = isGrid
|
||||||
|
|
||||||
|
configureThumbnail(file, mode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
|
backgroundColor = .clear
|
||||||
contentView.clipsToBounds = true
|
contentView.clipsToBounds = true
|
||||||
contentView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
|
contentView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
|
||||||
|
|
||||||
thumbnailView.contentMode = .scaleAspectFill
|
thumbnailView.contentMode = .scaleAspectFill
|
||||||
thumbnailView.clipsToBounds = true
|
thumbnailView.clipsToBounds = true
|
||||||
thumbnailView.backgroundColor = AppColor.warningBackground
|
|
||||||
|
|
||||||
folderIconView.tintColor = AppColor.warning
|
|
||||||
folderIconView.contentMode = .scaleAspectFit
|
folderIconView.contentMode = .scaleAspectFit
|
||||||
|
folderIconView.isHidden = true
|
||||||
|
|
||||||
playIconView.tintColor = .white
|
|
||||||
playIconView.contentMode = .scaleAspectFit
|
playIconView.contentMode = .scaleAspectFit
|
||||||
playIconView.isHidden = true
|
playIconView.isHidden = true
|
||||||
|
|
||||||
extensionLabel.font = .app(.caption)
|
extensionLabel.font = .systemFont(ofSize: 12, weight: .bold)
|
||||||
extensionLabel.textColor = .white
|
extensionLabel.textColor = .white
|
||||||
extensionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
extensionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||||
extensionLabel.layer.cornerRadius = AppRadius.xs
|
extensionLabel.layer.cornerRadius = 4
|
||||||
extensionLabel.clipsToBounds = true
|
extensionLabel.clipsToBounds = true
|
||||||
extensionLabel.isHidden = true
|
extensionLabel.isHidden = true
|
||||||
|
|
||||||
nameLabel.font = .app(.body)
|
nameLabel.textColor = UIColor(hex: 0x333333)
|
||||||
nameLabel.textColor = AppColor.textPrimary
|
|
||||||
nameLabel.numberOfLines = 1
|
nameLabel.numberOfLines = 1
|
||||||
|
|
||||||
detailLabel.font = .app(.caption)
|
detailLabel.font = .systemFont(ofSize: 12)
|
||||||
detailLabel.textColor = UIColor(hex: 0x4B5563)
|
|
||||||
detailLabel.numberOfLines = 1
|
detailLabel.numberOfLines = 1
|
||||||
|
|
||||||
moreButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "ellipsis"), for: .normal)
|
let detailsImage = CloudDriveAsset.resizedImage(
|
||||||
moreButton.tintColor = AppColor.textSecondary
|
named: "icon_cloud_storage_file_item_details",
|
||||||
|
fallbackSystemName: "ellipsis",
|
||||||
|
size: CGSize(width: 16, height: 16)
|
||||||
|
)
|
||||||
|
moreButton.setImage(detailsImage, for: .normal)
|
||||||
|
moreButton.imageView?.contentMode = .scaleAspectFit
|
||||||
moreButton.addTarget(self, action: #selector(moreTapped), for: .touchUpInside)
|
moreButton.addTarget(self, action: #selector(moreTapped), for: .touchUpInside)
|
||||||
|
moreButton.accessibilityLabel = "文件操作"
|
||||||
|
|
||||||
contentView.addSubview(thumbnailView)
|
[thumbnailView, folderIconView, playIconView, extensionLabel, nameLabel, detailLabel, moreButton]
|
||||||
contentView.addSubview(folderIconView)
|
.forEach(contentView.addSubview)
|
||||||
contentView.addSubview(playIconView)
|
|
||||||
contentView.addSubview(extensionLabel)
|
|
||||||
contentView.addSubview(nameLabel)
|
|
||||||
contentView.addSubview(detailLabel)
|
|
||||||
contentView.addSubview(moreButton)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupConstraints() {
|
private func setupConstraints() {
|
||||||
thumbnailView.snp.makeConstraints { make in
|
thumbnailView.snp.makeConstraints { make in
|
||||||
gridConstraints.append(make.top.leading.trailing.equalToSuperview().constraint)
|
gridConstraints.append(make.top.leading.trailing.equalToSuperview().constraint)
|
||||||
gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).multipliedBy(0.56).constraint)
|
gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).dividedBy(1.77).constraint)
|
||||||
listConstraints.append(make.leading.centerY.equalToSuperview().constraint)
|
listConstraints.append(make.leading.centerY.equalToSuperview().constraint)
|
||||||
listConstraints.append(make.width.height.equalTo(48).constraint)
|
listConstraints.append(make.width.height.equalTo(48).constraint)
|
||||||
}
|
}
|
||||||
folderIconView.snp.makeConstraints { make in
|
folderIconView.snp.makeConstraints { make in
|
||||||
make.center.equalTo(thumbnailView)
|
make.center.equalTo(thumbnailView)
|
||||||
make.width.height.equalTo(42)
|
make.width.height.equalTo(48)
|
||||||
}
|
}
|
||||||
playIconView.snp.makeConstraints { make in
|
playIconView.snp.makeConstraints { make in
|
||||||
make.center.equalTo(thumbnailView)
|
make.center.equalTo(thumbnailView)
|
||||||
make.width.height.equalTo(28)
|
make.width.height.equalTo(24)
|
||||||
}
|
}
|
||||||
extensionLabel.snp.makeConstraints { make in
|
extensionLabel.snp.makeConstraints { make in
|
||||||
make.top.leading.equalTo(thumbnailView).offset(AppSpacing.xs)
|
make.top.leading.equalTo(thumbnailView).offset(8)
|
||||||
}
|
}
|
||||||
nameLabel.snp.makeConstraints { make in
|
nameLabel.snp.makeConstraints { make in
|
||||||
gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(AppSpacing.xs).constraint)
|
gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(8).constraint)
|
||||||
gridConstraints.append(make.leading.equalToSuperview().offset(AppSpacing.sm).constraint)
|
gridConstraints.append(make.leading.equalToSuperview().offset(12).constraint)
|
||||||
listConstraints.append(make.top.equalToSuperview().offset(AppSpacing.sm).constraint)
|
listConstraints.append(make.top.equalToSuperview().offset(12).constraint)
|
||||||
listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(AppSpacing.sm).constraint)
|
listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(12).constraint)
|
||||||
make.trailing.equalTo(moreButton.snp.leading).offset(-AppSpacing.xs)
|
make.trailing.equalTo(moreButton.snp.leading).offset(-4)
|
||||||
}
|
}
|
||||||
detailLabel.snp.makeConstraints { make in
|
detailLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(nameLabel.snp.bottom).offset(AppSpacing.xxs)
|
make.top.equalTo(nameLabel.snp.bottom).offset(2)
|
||||||
make.leading.equalTo(nameLabel)
|
make.leading.trailing.equalTo(nameLabel)
|
||||||
make.trailing.equalTo(nameLabel)
|
|
||||||
}
|
}
|
||||||
moreButton.snp.makeConstraints { make in
|
moreButton.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview().inset(AppSpacing.xs)
|
gridConstraints.append(make.trailing.equalToSuperview().inset(10).constraint)
|
||||||
|
listConstraints.append(make.trailing.equalToSuperview().constraint)
|
||||||
make.centerY.equalTo(nameLabel.snp.bottom).offset(4)
|
make.centerY.equalTo(nameLabel.snp.bottom).offset(4)
|
||||||
make.width.height.equalTo(32)
|
make.width.height.equalTo(28)
|
||||||
}
|
}
|
||||||
listConstraints.forEach { $0.deactivate() }
|
listConstraints.forEach { $0.deactivate() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureThumbnail(_ file: CloudFile) {
|
private func configureThumbnail(_ file: CloudFile, mode: CloudDriveDisplayMode) {
|
||||||
if file.isFolder {
|
thumbnailView.kf.cancelDownloadTask()
|
||||||
thumbnailView.image = nil
|
folderIconView.isHidden = true
|
||||||
thumbnailView.backgroundColor = AppColor.warningBackground
|
playIconView.isHidden = true
|
||||||
folderIconView.isHidden = false
|
|
||||||
extensionLabel.isHidden = true
|
extensionLabel.isHidden = true
|
||||||
|
|
||||||
|
if mode == .list {
|
||||||
|
thumbnailView.backgroundColor = .clear
|
||||||
|
thumbnailView.contentMode = .scaleAspectFit
|
||||||
|
let assetName: String
|
||||||
|
let fallbackName: String
|
||||||
|
if file.isFolder {
|
||||||
|
assetName = "icon_cloud_storage_file_type_folder"
|
||||||
|
fallbackName = "folder.fill"
|
||||||
|
} else if file.isVideo {
|
||||||
|
assetName = "icon_cloud_storage_file_type_video"
|
||||||
|
fallbackName = "video.fill"
|
||||||
|
} else {
|
||||||
|
assetName = "icon_cloud_storage_file_type_photo"
|
||||||
|
fallbackName = "photo.fill"
|
||||||
|
}
|
||||||
|
thumbnailView.image = CloudDriveAsset.image(named: assetName, fallbackSystemName: fallbackName)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderIconView.isHidden = true
|
|
||||||
thumbnailView.backgroundColor = AppColor.inputBackground
|
thumbnailView.contentMode = .scaleAspectFill
|
||||||
let preview = file.isVideo ? file.coverUrl : file.fileUrl
|
if file.isFolder {
|
||||||
if let url = URL(string: preview), !preview.isEmpty {
|
thumbnailView.image = nil
|
||||||
|
thumbnailView.backgroundColor = UIColor(hex: 0xFFF0E2)
|
||||||
|
folderIconView.isHidden = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailView.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
|
let previewURL = file.isVideo ? file.coverUrl : file.fileUrl
|
||||||
let placeholder = CloudDriveAsset.image(
|
let placeholder = CloudDriveAsset.image(
|
||||||
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
|
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
|
||||||
fallbackSystemName: file.isVideo ? "video" : "photo"
|
fallbackSystemName: file.isVideo ? "video" : "photo"
|
||||||
)
|
)
|
||||||
|
if let url = URL(string: previewURL), !previewURL.isEmpty {
|
||||||
thumbnailView.kf.setImage(with: url, placeholder: placeholder)
|
thumbnailView.kf.setImage(with: url, placeholder: placeholder)
|
||||||
} else {
|
} else {
|
||||||
thumbnailView.image = CloudDriveAsset.image(
|
thumbnailView.image = placeholder
|
||||||
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
|
playIconView.isHidden = !file.isVideo
|
||||||
extensionLabel.text = CloudDriveFormatter.fileExtension(file.fileUrl.isEmpty ? file.name : file.fileUrl)
|
extensionLabel.text = CloudDriveFormatter.fileExtension(file.fileUrl.isEmpty ? file.name : file.fileUrl)
|
||||||
extensionLabel.isHidden = false
|
extensionLabel.isHidden = extensionLabel.text?.isEmpty ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func moreTapped() {
|
@objc private func moreTapped() {
|
||||||
@ -174,16 +204,19 @@ final class CloudDriveFileCell: UICollectionViewCell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 带内边距的标签。
|
/// 云盘文件扩展名标签,提供 Android 对齐的四边内边距。
|
||||||
private final class CloudDrivePaddingLabel: UILabel {
|
private final class CloudDrivePaddingLabel: UILabel {
|
||||||
var insets = UIEdgeInsets(top: 3, left: 5, bottom: 3, right: 5)
|
private let insets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
|
||||||
|
|
||||||
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) {
|
override func drawText(in rect: CGRect) {
|
||||||
super.drawText(in: rect.inset(by: insets))
|
super.drawText(in: rect.inset(by: insets))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
let size = super.intrinsicContentSize
|
||||||
|
return CGSize(
|
||||||
|
width: size.width + insets.left + insets.right,
|
||||||
|
height: size.height + insets.top + insets.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,15 +24,16 @@ enum CloudDriveFormatter {
|
|||||||
|
|
||||||
/// 文件描述。
|
/// 文件描述。
|
||||||
static func fileDescription(_ file: CloudFile) -> String {
|
static func fileDescription(_ file: CloudFile) -> String {
|
||||||
|
let createdDate = dateOnly(file.createdAt)
|
||||||
if file.isFolder {
|
if file.isFolder {
|
||||||
return "\(file.childNum) 个项目"
|
return "\(createdDate) | \(file.childNum)个项目"
|
||||||
}
|
}
|
||||||
return "\(typeName(file.type)) \(fileSize(file.fileSize))"
|
return "\(createdDate) | \(fileSize(file.fileSize))"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 文件大小展示。
|
/// 文件大小展示。
|
||||||
static func fileSize(_ bytes: Int64) -> String {
|
static func fileSize(_ bytes: Int64) -> String {
|
||||||
guard bytes > 0 else { return "0B" }
|
guard bytes > 0 else { return "0 B" }
|
||||||
let units = ["B", "KB", "MB", "GB", "TB"]
|
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
var value = Double(bytes)
|
var value = Double(bytes)
|
||||||
var index = 0
|
var index = 0
|
||||||
@ -40,10 +41,17 @@ enum CloudDriveFormatter {
|
|||||||
value /= 1024
|
value /= 1024
|
||||||
index += 1
|
index += 1
|
||||||
}
|
}
|
||||||
if index == 0 {
|
return String(format: "%.2f %@", value, units[index])
|
||||||
return "\(Int(value))\(units[index])"
|
|
||||||
}
|
}
|
||||||
return String(format: "%.1f%@", value, units[index])
|
|
||||||
|
/// 将后端时间转换为 Android 列表使用的 yyyy-MM-dd 日期。
|
||||||
|
static func dateOnly(_ value: String) -> String {
|
||||||
|
guard !value.isEmpty else { return "" }
|
||||||
|
if value.count >= 10 {
|
||||||
|
let endIndex = value.index(value.startIndex, offsetBy: 10)
|
||||||
|
return String(value[..<endIndex])
|
||||||
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL 或文件名的后缀。
|
/// URL 或文件名的后缀。
|
||||||
@ -59,4 +67,13 @@ enum CloudDriveAsset {
|
|||||||
static func image(named name: String, fallbackSystemName: String) -> UIImage? {
|
static func image(named name: String, fallbackSystemName: String) -> UIImage? {
|
||||||
UIImage(named: name)?.withRenderingMode(.alwaysOriginal) ?? UIImage(systemName: fallbackSystemName)
|
UIImage(named: name)?.withRenderingMode(.alwaysOriginal) ?? UIImage(systemName: fallbackSystemName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回缩放到指定点尺寸的云盘图标。
|
||||||
|
static func resizedImage(named name: String, fallbackSystemName: String, size: CGSize) -> UIImage? {
|
||||||
|
guard let source = image(named: name, fallbackSystemName: fallbackSystemName) else { return nil }
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
return renderer.image { _ in
|
||||||
|
source.draw(in: CGRect(origin: .zero, size: size))
|
||||||
|
}.withRenderingMode(.alwaysOriginal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -8,13 +8,17 @@ import Kingfisher
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 云盘文件预览页,黑底展示图片或视频。
|
/// 云盘文件预览页,严格对齐 Android 黑底预览并保留 iOS 原生视频控制层。
|
||||||
final class CloudDrivePreviewViewController: BaseViewController {
|
final class CloudDrivePreviewViewController: BaseViewController {
|
||||||
private let file: CloudFile
|
private let file: CloudFile
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
private var playerViewController: AVPlayerViewController?
|
private var playerViewController: AVPlayerViewController?
|
||||||
|
private var previousStandardAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousCompactAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousTintColor: UIColor?
|
||||||
|
|
||||||
/// 初始化预览页。
|
/// 初始化云盘文件预览页。
|
||||||
init(file: CloudFile) {
|
init(file: CloudFile) {
|
||||||
self.file = file
|
self.file = file
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -25,9 +29,23 @@ final class CloudDrivePreviewViewController: BaseViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
.lightContent
|
||||||
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = ""
|
navigationItem.titleView = UIView()
|
||||||
navigationController?.navigationBar.tintColor = .white
|
navigationItem.hidesBackButton = true
|
||||||
|
let backButton = UIButton(type: .system)
|
||||||
|
backButton.setImage(
|
||||||
|
UIImage(systemName: "chevron.left")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)),
|
||||||
|
for: .normal
|
||||||
|
)
|
||||||
|
backButton.tintColor = .white
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
@ -39,37 +57,71 @@ final class CloudDrivePreviewViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
applyTransparentNavigationBar()
|
||||||
|
setNeedsStatusBarAppearanceUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
navigationController?.navigationBar.tintColor = AppColor.primary
|
restoreNavigationBar()
|
||||||
playerViewController?.player?.pause()
|
playerViewController?.player?.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupImage() {
|
private func setupImage() {
|
||||||
imageView.contentMode = .scaleAspectFit
|
imageView.contentMode = .scaleAspectFit
|
||||||
imageView.backgroundColor = .black
|
imageView.backgroundColor = .black
|
||||||
|
imageView.accessibilityLabel = file.name
|
||||||
view.addSubview(imageView)
|
view.addSubview(imageView)
|
||||||
imageView.snp.makeConstraints { make in
|
imageView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) }
|
||||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
if let url = URL(string: file.fileUrl), !file.fileUrl.isEmpty {
|
||||||
}
|
|
||||||
if let url = URL(string: file.fileUrl) {
|
|
||||||
imageView.kf.setImage(with: url)
|
imageView.kf.setImage(with: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupVideo() {
|
private func setupVideo() {
|
||||||
guard let url = URL(string: file.fileUrl) else { return }
|
guard let url = URL(string: file.fileUrl), !file.fileUrl.isEmpty else { return }
|
||||||
let player = AVPlayer(url: url)
|
let player = AVPlayer(url: url)
|
||||||
let controller = AVPlayerViewController()
|
let controller = AVPlayerViewController()
|
||||||
controller.player = player
|
controller.player = player
|
||||||
controller.view.backgroundColor = .black
|
controller.view.backgroundColor = .black
|
||||||
addChild(controller)
|
addChild(controller)
|
||||||
view.addSubview(controller.view)
|
view.addSubview(controller.view)
|
||||||
controller.view.snp.makeConstraints { make in
|
controller.view.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) }
|
||||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
|
||||||
}
|
|
||||||
controller.didMove(toParent: self)
|
controller.didMove(toParent: self)
|
||||||
playerViewController = controller
|
playerViewController = controller
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyTransparentNavigationBar() {
|
||||||
|
guard let navigationBar = navigationController?.navigationBar else { return }
|
||||||
|
previousStandardAppearance = navigationBar.standardAppearance
|
||||||
|
previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance
|
||||||
|
previousCompactAppearance = navigationBar.compactAppearance
|
||||||
|
previousTintColor = navigationBar.tintColor
|
||||||
|
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithTransparentBackground()
|
||||||
|
appearance.backgroundColor = .clear
|
||||||
|
appearance.shadowColor = .clear
|
||||||
|
navigationBar.standardAppearance = appearance
|
||||||
|
navigationBar.scrollEdgeAppearance = appearance
|
||||||
|
navigationBar.compactAppearance = appearance
|
||||||
|
navigationBar.tintColor = .white
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreNavigationBar() {
|
||||||
|
guard let navigationBar = navigationController?.navigationBar else { return }
|
||||||
|
if let previousStandardAppearance {
|
||||||
|
navigationBar.standardAppearance = previousStandardAppearance
|
||||||
|
}
|
||||||
|
navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance
|
||||||
|
navigationBar.compactAppearance = previousCompactAppearance
|
||||||
|
navigationBar.tintColor = previousTintColor
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func backTapped() {
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,21 +6,34 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 云盘传输管理页,对齐 Android `CloudStorageTransitScreen`。
|
/// 云盘传输管理页,严格对齐 Android `CloudStorageTransitScreen`。
|
||||||
final class CloudDriveTransferViewController: BaseViewController {
|
final class CloudDriveTransferViewController: BaseViewController {
|
||||||
private enum Tab: Int {
|
private enum Tab: Int {
|
||||||
case upload
|
case upload
|
||||||
case download
|
case download
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum Section {
|
||||||
|
case main
|
||||||
|
}
|
||||||
|
|
||||||
private let manager: CloudTransferManager
|
private let manager: CloudTransferManager
|
||||||
private let segmentedControl = UISegmentedControl(items: ["上传", "下载"])
|
private let navigationDivider = UIView()
|
||||||
|
private let tabContainer = UIView()
|
||||||
|
private let uploadTabButton = UIButton(type: .system)
|
||||||
|
private let downloadTabButton = UIButton(type: .system)
|
||||||
|
private let tabIndicator = UIView()
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Section, String>!
|
||||||
private var selectedTab: Tab = .upload
|
private var selectedTab: Tab = .upload
|
||||||
|
private var previousStandardAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousCompactAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousNavigationTintColor: UIColor?
|
||||||
|
|
||||||
/// 初始化传输管理页。
|
/// 初始化传输管理页。
|
||||||
init(manager: CloudTransferManager = .shared) {
|
init(manager: CloudTransferManager? = nil) {
|
||||||
self.manager = manager
|
self.manager = manager ?? .shared
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,69 +43,151 @@ final class CloudDriveTransferViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "传输管理"
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = AppColor.pageBackground
|
view.backgroundColor = AppColor.pageBackground
|
||||||
segmentedControl.selectedSegmentIndex = selectedTab.rawValue
|
navigationDivider.backgroundColor = UIColor(hex: 0xEEEEEE)
|
||||||
|
tabContainer.backgroundColor = .white
|
||||||
|
configureTabButton(uploadTabButton, title: "上传", action: #selector(uploadTabTapped))
|
||||||
|
configureTabButton(downloadTabButton, title: "下载", action: #selector(downloadTabTapped))
|
||||||
|
tabIndicator.backgroundColor = AppColor.primary
|
||||||
|
|
||||||
tableView.backgroundColor = AppColor.pageBackground
|
tableView.backgroundColor = AppColor.pageBackground
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.dataSource = self
|
tableView.showsVerticalScrollIndicator = false
|
||||||
|
tableView.contentInsetAdjustmentBehavior = .never
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.register(CloudTransferCell.self, forCellReuseIdentifier: CloudTransferCell.reuseIdentifier)
|
tableView.register(CloudTransferCell.self, forCellReuseIdentifier: CloudTransferCell.reuseIdentifier)
|
||||||
|
|
||||||
view.addSubview(segmentedControl)
|
view.addSubview(navigationDivider)
|
||||||
|
view.addSubview(tabContainer)
|
||||||
|
tabContainer.addSubview(uploadTabButton)
|
||||||
|
tabContainer.addSubview(downloadTabButton)
|
||||||
|
tabContainer.addSubview(tabIndicator)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
|
|
||||||
|
configureDataSource()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
segmentedControl.snp.makeConstraints { make in
|
navigationDivider.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
make.leading.trailing.equalToSuperview()
|
||||||
make.height.equalTo(36)
|
make.height.equalTo(0.5)
|
||||||
|
}
|
||||||
|
tabContainer.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(navigationDivider.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
uploadTabButton.snp.makeConstraints { make in
|
||||||
|
make.top.leading.bottom.equalToSuperview()
|
||||||
|
make.width.equalToSuperview().multipliedBy(0.5)
|
||||||
|
}
|
||||||
|
downloadTabButton.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.bottom.equalToSuperview()
|
||||||
|
make.width.equalTo(uploadTabButton)
|
||||||
|
}
|
||||||
|
tabIndicator.snp.makeConstraints { make in
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(2)
|
||||||
|
make.width.equalToSuperview().multipliedBy(0.5)
|
||||||
|
make.leading.equalToSuperview()
|
||||||
}
|
}
|
||||||
tableView.snp.makeConstraints { make in
|
tableView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.sm)
|
make.top.equalTo(tabContainer.snp.bottom)
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
|
|
||||||
manager.onTasksChange = { [weak self] in
|
manager.onTasksChange = { [weak self] in
|
||||||
Task { @MainActor in self?.tableView.reloadData() }
|
Task { @MainActor in self?.applySnapshot(animated: true) }
|
||||||
}
|
}
|
||||||
|
updateTabs(animated: false)
|
||||||
|
applySnapshot(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
applyCloudNavigationBarAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
restoreNavigationBarAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var visibleTasks: [CloudTransferTask] {
|
private var visibleTasks: [CloudTransferTask] {
|
||||||
selectedTab == .upload ? manager.uploadTasks : manager.downloadTasks
|
selectedTab == .upload ? manager.uploadTasks : manager.downloadTasks
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func tabChanged() {
|
private func configureTabButton(_ button: UIButton, title: String, action: Selector) {
|
||||||
selectedTab = Tab(rawValue: segmentedControl.selectedSegmentIndex) ?? .upload
|
button.setTitle(title, for: .normal)
|
||||||
tableView.reloadData()
|
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
}
|
button.addTarget(self, action: action, for: .touchUpInside)
|
||||||
}
|
|
||||||
|
|
||||||
extension CloudDriveTransferViewController: UITableViewDataSource, UITableViewDelegate {
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
||||||
visibleTasks.count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
private func applyCloudNavigationBarAppearance() {
|
||||||
112
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
private func restoreNavigationBarAppearance() {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: CloudTransferCell.reuseIdentifier, for: indexPath) as! CloudTransferCell
|
guard let navigationBar = navigationController?.navigationBar else { return }
|
||||||
let task = visibleTasks[indexPath.row]
|
if let previousStandardAppearance {
|
||||||
|
navigationBar.standardAppearance = previousStandardAppearance
|
||||||
|
}
|
||||||
|
navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance
|
||||||
|
navigationBar.compactAppearance = previousCompactAppearance
|
||||||
|
navigationBar.tintColor = previousNavigationTintColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureDataSource() {
|
||||||
|
dataSource = UITableViewDiffableDataSource<Section, String>(tableView: tableView) { [weak self] tableView, indexPath, taskID in
|
||||||
|
guard let self,
|
||||||
|
let task = self.visibleTasks.first(where: { $0.id == taskID }),
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: CloudTransferCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as? CloudTransferCell else {
|
||||||
|
return UITableViewCell()
|
||||||
|
}
|
||||||
cell.apply(task: task)
|
cell.apply(task: task)
|
||||||
cell.onPrimaryAction = { [weak self] in
|
cell.onPrimaryAction = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if task.kind == .upload {
|
switch task.kind {
|
||||||
|
case .upload:
|
||||||
task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id)
|
task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id)
|
||||||
} else {
|
case .download:
|
||||||
task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id)
|
task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -102,9 +197,63 @@ extension CloudDriveTransferViewController: UITableViewDataSource, UITableViewDe
|
|||||||
}
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
dataSource.defaultRowAnimation = .fade
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySnapshot(animated: Bool) {
|
||||||
|
let previousIDs = Set(dataSource.snapshot().itemIdentifiers)
|
||||||
|
let taskIDs = visibleTasks.map(\.id)
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(taskIDs)
|
||||||
|
snapshot.reconfigureItems(taskIDs.filter(previousIDs.contains))
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTabs(animated: Bool) {
|
||||||
|
let uploadSelected = selectedTab == .upload
|
||||||
|
uploadTabButton.setTitleColor(uploadSelected ? AppColor.primary : UIColor(hex: 0x333333), for: .normal)
|
||||||
|
downloadTabButton.setTitleColor(uploadSelected ? UIColor(hex: 0x333333) : AppColor.primary, for: .normal)
|
||||||
|
uploadTabButton.titleLabel?.font = .systemFont(ofSize: 14, weight: uploadSelected ? .medium : .regular)
|
||||||
|
downloadTabButton.titleLabel?.font = .systemFont(ofSize: 14, weight: uploadSelected ? .regular : .medium)
|
||||||
|
tabIndicator.snp.updateConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(uploadSelected ? 0 : view.bounds.width / 2)
|
||||||
|
}
|
||||||
|
guard animated else {
|
||||||
|
tabContainer.layoutIfNeeded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UIView.animate(withDuration: 0.2) { self.tabContainer.layoutIfNeeded() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectTab(_ tab: Tab) {
|
||||||
|
guard selectedTab != tab else { return }
|
||||||
|
selectedTab = tab
|
||||||
|
updateTabs(animated: true)
|
||||||
|
applySnapshot(animated: true)
|
||||||
|
tableView.setContentOffset(.zero, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func backTapped() {
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func uploadTabTapped() {
|
||||||
|
selectTab(.upload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func downloadTabTapped() {
|
||||||
|
selectTab(.download)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 云盘传输任务 Cell。
|
extension CloudDriveTransferViewController: UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
119
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘传输任务 Cell,严格对齐 Android 上传/下载任务卡片。
|
||||||
private final class CloudTransferCell: UITableViewCell {
|
private final class CloudTransferCell: UITableViewCell {
|
||||||
static let reuseIdentifier = "CloudTransferCell"
|
static let reuseIdentifier = "CloudTransferCell"
|
||||||
|
|
||||||
@ -137,90 +286,124 @@ private final class CloudTransferCell: UITableViewCell {
|
|||||||
onCancel = nil
|
onCancel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 应用传输任务状态。
|
||||||
func apply(task: CloudTransferTask) {
|
func apply(task: CloudTransferTask) {
|
||||||
iconView.image = CloudDriveAsset.image(
|
iconView.image = CloudDriveAsset.image(
|
||||||
named: task.fileType == 1 ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
|
named: task.fileType == 1 ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
|
||||||
fallbackSystemName: task.fileType == 1 ? "video.fill" : "photo.fill"
|
fallbackSystemName: task.fileType == 1 ? "video.fill" : "photo.fill"
|
||||||
)
|
)
|
||||||
titleLabel.text = task.fileName
|
titleLabel.text = task.fileName
|
||||||
statusLabel.text = "\(task.status.title) \(CloudDriveFormatter.fileSize(task.fileSize))"
|
statusLabel.text = statusText(for: task)
|
||||||
progressView.progress = Float(task.progress) / 100
|
progressView.progress = Float(task.progress) / 100
|
||||||
percentLabel.text = "\(task.progress)%"
|
percentLabel.text = "\(task.progress)%"
|
||||||
|
|
||||||
let isRunning = task.status == .uploading || task.status == .downloading
|
let isRunning = task.status == .uploading || task.status == .downloading
|
||||||
primaryButton.setImage(UIImage(systemName: isRunning ? "pause.fill" : "play.fill"), for: .normal)
|
let canResume = task.status == .paused || task.status == .failed
|
||||||
primaryButton.isHidden = task.status == .completed
|
primaryButton.setImage(
|
||||||
|
UIImage(systemName: isRunning ? "pause.fill" : "play.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20)),
|
||||||
|
for: .normal
|
||||||
|
)
|
||||||
|
primaryButton.isHidden = !(isRunning || canResume)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusText(for task: CloudTransferTask) -> String {
|
||||||
|
switch task.kind {
|
||||||
|
case .upload:
|
||||||
|
let status: String
|
||||||
|
switch task.status {
|
||||||
|
case .uploading: status = "上传中"
|
||||||
|
case .paused: status = "已暂停"
|
||||||
|
case .failed: status = "失败"
|
||||||
|
default: status = ""
|
||||||
|
}
|
||||||
|
let size = CloudDriveFormatter.fileSize(task.fileSize)
|
||||||
|
return status.isEmpty ? size : "\(status) \(size)"
|
||||||
|
case .download:
|
||||||
|
switch task.status {
|
||||||
|
case .downloading: return "下载中"
|
||||||
|
case .paused: return "已暂停"
|
||||||
|
case .failed: return "失败"
|
||||||
|
case .completed: return "已完成"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
cardView.backgroundColor = .white
|
cardView.backgroundColor = .white
|
||||||
cardView.layer.cornerRadius = AppRadius.sm
|
cardView.layer.cornerRadius = 8
|
||||||
cardView.clipsToBounds = true
|
cardView.clipsToBounds = true
|
||||||
|
|
||||||
iconView.tintColor = AppColor.primary
|
|
||||||
iconView.contentMode = .scaleAspectFit
|
iconView.contentMode = .scaleAspectFit
|
||||||
titleLabel.font = .app(.body)
|
titleLabel.font = .systemFont(ofSize: 15)
|
||||||
titleLabel.textColor = AppColor.textPrimary
|
titleLabel.textColor = UIColor(hex: 0x333333)
|
||||||
titleLabel.lineBreakMode = .byTruncatingMiddle
|
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
statusLabel.font = .app(.caption)
|
statusLabel.font = .systemFont(ofSize: 12)
|
||||||
statusLabel.textColor = AppColor.textTertiary
|
statusLabel.textColor = .gray
|
||||||
progressView.progressTintColor = AppColor.primary
|
progressView.progressTintColor = AppColor.primary
|
||||||
percentLabel.font = .app(.caption)
|
progressView.trackTintColor = UIColor(hex: 0xE5E7EB)
|
||||||
percentLabel.textColor = AppColor.textSecondary
|
progressView.transform = CGAffineTransform(scaleX: 1, y: 2)
|
||||||
|
percentLabel.font = .systemFont(ofSize: 11)
|
||||||
|
percentLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
percentLabel.textAlignment = .right
|
percentLabel.textAlignment = .right
|
||||||
|
|
||||||
primaryButton.tintColor = AppColor.primary
|
primaryButton.tintColor = UIColor(hex: 0x333333)
|
||||||
cancelButton.tintColor = AppColor.textSecondary
|
cancelButton.tintColor = UIColor(hex: 0x333333)
|
||||||
cancelButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
cancelButton.setImage(
|
||||||
|
UIImage(systemName: "xmark")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20)),
|
||||||
|
for: .normal
|
||||||
|
)
|
||||||
|
primaryButton.accessibilityLabel = "暂停或继续"
|
||||||
|
cancelButton.accessibilityLabel = "取消任务"
|
||||||
primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
|
primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
|
||||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
|
||||||
contentView.addSubview(cardView)
|
contentView.addSubview(cardView)
|
||||||
cardView.addSubview(iconView)
|
[iconView, titleLabel, statusLabel, primaryButton, cancelButton, progressView, percentLabel]
|
||||||
cardView.addSubview(titleLabel)
|
.forEach(cardView.addSubview)
|
||||||
cardView.addSubview(statusLabel)
|
|
||||||
cardView.addSubview(primaryButton)
|
|
||||||
cardView.addSubview(cancelButton)
|
|
||||||
cardView.addSubview(progressView)
|
|
||||||
cardView.addSubview(percentLabel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupConstraints() {
|
private func setupConstraints() {
|
||||||
cardView.snp.makeConstraints { make in
|
cardView.snp.makeConstraints { make in
|
||||||
make.top.bottom.equalToSuperview().inset(AppSpacing.xs)
|
make.top.equalToSuperview().offset(12)
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
iconView.snp.makeConstraints { make in
|
iconView.snp.makeConstraints { make in
|
||||||
make.leading.top.equalToSuperview().offset(AppSpacing.sm)
|
make.leading.top.equalToSuperview().offset(12)
|
||||||
make.width.height.equalTo(48)
|
make.width.height.equalTo(48)
|
||||||
}
|
}
|
||||||
cancelButton.snp.makeConstraints { make in
|
cancelButton.snp.makeConstraints { make in
|
||||||
make.trailing.top.equalToSuperview().inset(AppSpacing.xs)
|
make.trailing.equalToSuperview()
|
||||||
make.width.height.equalTo(36)
|
make.top.equalToSuperview().offset(8)
|
||||||
|
make.width.height.equalTo(44)
|
||||||
}
|
}
|
||||||
primaryButton.snp.makeConstraints { make in
|
primaryButton.snp.makeConstraints { make in
|
||||||
make.trailing.equalTo(cancelButton.snp.leading)
|
make.trailing.equalTo(cancelButton.snp.leading)
|
||||||
make.centerY.equalTo(cancelButton)
|
make.centerY.equalTo(cancelButton)
|
||||||
make.width.height.equalTo(36)
|
make.width.height.equalTo(44)
|
||||||
}
|
}
|
||||||
titleLabel.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm)
|
make.leading.equalTo(iconView.snp.trailing).offset(12)
|
||||||
make.top.equalTo(iconView)
|
make.top.equalTo(iconView)
|
||||||
make.trailing.equalTo(primaryButton.snp.leading).offset(-AppSpacing.xs)
|
make.trailing.equalTo(primaryButton.snp.leading).offset(-4)
|
||||||
}
|
}
|
||||||
statusLabel.snp.makeConstraints { make in
|
statusLabel.snp.makeConstraints { make in
|
||||||
make.leading.trailing.equalTo(titleLabel)
|
make.leading.trailing.equalTo(titleLabel)
|
||||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xxs)
|
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||||
}
|
}
|
||||||
progressView.snp.makeConstraints { make in
|
progressView.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(iconView)
|
make.leading.equalToSuperview().offset(12)
|
||||||
make.trailing.equalToSuperview().inset(AppSpacing.sm)
|
make.trailing.equalToSuperview().inset(12)
|
||||||
make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm)
|
make.top.equalTo(iconView.snp.bottom).offset(12)
|
||||||
|
make.height.equalTo(4)
|
||||||
}
|
}
|
||||||
percentLabel.snp.makeConstraints { make in
|
percentLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(progressView.snp.bottom).offset(AppSpacing.xxs)
|
make.top.equalTo(progressView.snp.bottom).offset(4)
|
||||||
make.trailing.equalTo(progressView)
|
make.trailing.equalTo(progressView)
|
||||||
|
make.bottom.equalToSuperview().inset(12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import UIKit
|
|||||||
|
|
||||||
/// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。
|
/// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。
|
||||||
final class WildReportRiskMapViewController: BaseViewController {
|
final class WildReportRiskMapViewController: BaseViewController {
|
||||||
|
private static let cachedNavigationAppKey = "wildReportRiskMap.cachedNavigationApp"
|
||||||
|
|
||||||
private let viewModel: WildReportRiskMapViewModel
|
private let viewModel: WildReportRiskMapViewModel
|
||||||
private let api: any WildPhotographerReportServing
|
private let api: any WildPhotographerReportServing
|
||||||
private let locationProvider: any LocationProviding
|
private let locationProvider: any LocationProviding
|
||||||
@ -907,6 +909,10 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let target = viewModel.navigateToSelectedMarker() else { return }
|
guard let target = viewModel.navigateToSelectedMarker() else { return }
|
||||||
|
if let cachedOption = cachedNavigationOption(for: target) {
|
||||||
|
openNavigation(cachedOption, cacheSelection: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
presentNavigationOptions(for: target)
|
presentNavigationOptions(for: target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -930,7 +936,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet)
|
let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet)
|
||||||
options.forEach { option in
|
options.forEach { option in
|
||||||
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
|
||||||
self?.openNavigation(option)
|
self?.openNavigation(option, cacheSelection: true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
@ -942,43 +948,71 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] {
|
private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] {
|
||||||
[
|
WildReportNavigationApp.allCases.compactMap { app in
|
||||||
WildReportNavigationOption(title: "Apple 地图", url: navigationURL(for: .apple, target: target)),
|
guard let option = navigationOption(for: app, target: target) else { return nil }
|
||||||
WildReportNavigationOption(title: "高德地图", url: navigationURL(for: .amap, target: target)),
|
guard isNavigationOptionAvailable(option) else { return nil }
|
||||||
WildReportNavigationOption(title: "百度地图", url: navigationURL(for: .baidu, target: target)),
|
|
||||||
WildReportNavigationOption(title: "腾讯地图", url: navigationURL(for: .tencent, target: target)),
|
|
||||||
WildReportNavigationOption(title: "Google Maps", url: navigationURL(for: .google, target: target))
|
|
||||||
].compactMap { option in
|
|
||||||
guard option.url != nil else { return nil }
|
|
||||||
return option
|
return option
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openNavigation(_ option: WildReportNavigationOption) {
|
private func cachedNavigationOption(for target: WildReportMapMarker) -> WildReportNavigationOption? {
|
||||||
|
guard let rawValue = UserDefaults.standard.string(forKey: Self.cachedNavigationAppKey),
|
||||||
|
let app = WildReportNavigationApp(rawValue: rawValue),
|
||||||
|
let option = navigationOption(for: app, target: target),
|
||||||
|
isNavigationOptionAvailable(option)
|
||||||
|
else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigationOption(for app: WildReportNavigationApp, target: WildReportMapMarker) -> WildReportNavigationOption? {
|
||||||
|
WildReportNavigationOption(
|
||||||
|
app: app,
|
||||||
|
title: app.title,
|
||||||
|
url: navigationURL(for: app, target: target),
|
||||||
|
requiresAvailabilityCheck: app.requiresAvailabilityCheck
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isNavigationOptionAvailable(_ option: WildReportNavigationOption) -> Bool {
|
||||||
|
guard let url = option.url else { return false }
|
||||||
|
return !option.requiresAvailabilityCheck || UIApplication.shared.canOpenURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openNavigation(_ option: WildReportNavigationOption, cacheSelection: Bool) {
|
||||||
guard let url = option.url else { return }
|
guard let url = option.url else { return }
|
||||||
UIApplication.shared.open(url) { [weak self] success in
|
UIApplication.shared.open(url) { [weak self] success in
|
||||||
guard !success else { return }
|
guard success else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.showToast("未安装\(option.title)或无法打开")
|
self?.showToast("无法打开\(option.title)")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard cacheSelection else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
UserDefaults.standard.set(option.app.rawValue, forKey: Self.cachedNavigationAppKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? {
|
private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? {
|
||||||
let latitude = target.coordinate.latitude
|
let destLat = target.coordinate.latitude
|
||||||
let longitude = target.coordinate.longitude
|
let destLon = target.coordinate.longitude
|
||||||
let name = encodedNavigationText(target.title)
|
let name = encodedNavigationText(target.title)
|
||||||
switch app {
|
switch app {
|
||||||
case .apple:
|
case .apple:
|
||||||
return URL(string: "http://maps.apple.com/?daddr=\(latitude),\(longitude)&dirflg=d&q=\(name)")
|
return URL(string: "http://maps.apple.com/?daddr=\(destLat),\(destLon)&dirflg=d")
|
||||||
case .amap:
|
case .amap:
|
||||||
return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(latitude)&dlon=\(longitude)&dname=\(name)&dev=0&t=0")
|
return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(destLat)&dlon=\(destLon)&dname=\(name)&dev=0&t=0")
|
||||||
case .baidu:
|
case .baidu:
|
||||||
return URL(string: "baidumap://map/direction?destination=latlng:\(latitude),\(longitude)|name:\(name)&mode=driving&coord_type=wgs84")
|
return URL(string: "baidumap://map/direction?destination=latlng:\(destLat),\(destLon)|name:\(name)&mode=driving&coord_type=wgs84")
|
||||||
case .tencent:
|
case .tencent:
|
||||||
return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(latitude),\(longitude)&to=\(name)&referer=suixinkan")
|
return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(destLat),\(destLon)&to=\(name)&referer=suixinkan")
|
||||||
case .google:
|
case .google:
|
||||||
return URL(string: "comgooglemaps://?daddr=\(latitude),\(longitude)&directionsmode=driving")
|
return URL(string: "comgooglemaps://?daddr=\(destLat),\(destLon)&directionsmode=driving")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1022,17 +1056,40 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 风险地图导航软件选项。
|
||||||
private struct WildReportNavigationOption {
|
private struct WildReportNavigationOption {
|
||||||
|
let app: WildReportNavigationApp
|
||||||
let title: String
|
let title: String
|
||||||
let url: URL?
|
let url: URL?
|
||||||
|
let requiresAvailabilityCheck: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum WildReportNavigationApp {
|
/// 风险地图支持打开的导航软件。
|
||||||
|
private enum WildReportNavigationApp: String, CaseIterable {
|
||||||
case apple
|
case apple
|
||||||
case amap
|
case amap
|
||||||
case baidu
|
case baidu
|
||||||
case tencent
|
case tencent
|
||||||
case google
|
case google
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .apple:
|
||||||
|
return "Apple 地图"
|
||||||
|
case .amap:
|
||||||
|
return "高德地图"
|
||||||
|
case .baidu:
|
||||||
|
return "百度地图"
|
||||||
|
case .tencent:
|
||||||
|
return "腾讯地图"
|
||||||
|
case .google:
|
||||||
|
return "Google Maps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiresAvailabilityCheck: Bool {
|
||||||
|
self != .apple
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
||||||
|
|||||||
@ -9,12 +9,47 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 相册云盘 ViewModel 测试。
|
/// 相册云盘 ViewModel 测试。
|
||||||
final class CloudDriveViewModelTests: XCTestCase {
|
final class CloudDriveViewModelTests: XCTestCase {
|
||||||
|
func testDisplayModeToggleSwitchesBetweenGridAndListAndNotifiesUI() {
|
||||||
|
let viewModel = CloudDriveListViewModel()
|
||||||
|
var notificationCount = 0
|
||||||
|
viewModel.onStateChange = { notificationCount += 1 }
|
||||||
|
|
||||||
|
viewModel.toggleDisplayMode()
|
||||||
|
XCTAssertEqual(viewModel.displayMode, .list)
|
||||||
|
|
||||||
|
viewModel.toggleDisplayMode()
|
||||||
|
XCTAssertEqual(viewModel.displayMode, .grid)
|
||||||
|
XCTAssertEqual(notificationCount, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListFileDescriptionMatchesAndroidDateSizeAndFolderCount() {
|
||||||
|
let image = CloudFile(
|
||||||
|
id: 1,
|
||||||
|
name: "海边.jpg",
|
||||||
|
createdAt: "2026-07-10 18:32:15",
|
||||||
|
type: 2,
|
||||||
|
fileSize: 2_048
|
||||||
|
)
|
||||||
|
let folder = CloudFile(
|
||||||
|
id: 2,
|
||||||
|
name: "旅行",
|
||||||
|
createdAt: "2026-07-09 09:08:07",
|
||||||
|
childNum: 3,
|
||||||
|
type: 99
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(CloudDriveFormatter.fileDescription(image), "2026-07-10 | 2.00 KB")
|
||||||
|
XCTAssertEqual(CloudDriveFormatter.fileDescription(folder), "2026-07-09 | 3个项目")
|
||||||
|
XCTAssertEqual(CloudDriveFormatter.fileSize(0), "0 B")
|
||||||
|
}
|
||||||
|
|
||||||
func testLoadMoreSearchFilterSortAndFolderNavigation() async throws {
|
func testLoadMoreSearchFilterSortAndFolderNavigation() async throws {
|
||||||
let api = MockCloudDriveAPI()
|
let api = MockCloudDriveAPI()
|
||||||
api.listResponses = [
|
api.listResponses = [
|
||||||
CloudFileListResponse(total: 3, list: [file(id: 1), folder(id: 2)]),
|
CloudFileListResponse(total: 3, list: [file(id: 1), folder(id: 2)]),
|
||||||
CloudFileListResponse(total: 3, list: [file(id: 3)]),
|
CloudFileListResponse(total: 3, list: [file(id: 3)]),
|
||||||
CloudFileListResponse(total: 1, list: [file(id: 4)]),
|
CloudFileListResponse(total: 1, list: [file(id: 4)]),
|
||||||
|
CloudFileListResponse(total: 1, list: [file(id: 5)]),
|
||||||
CloudFileListResponse(total: 0, list: []),
|
CloudFileListResponse(total: 0, list: []),
|
||||||
]
|
]
|
||||||
let viewModel = CloudDriveListViewModel()
|
let viewModel = CloudDriveListViewModel()
|
||||||
@ -23,13 +58,16 @@ final class CloudDriveViewModelTests: XCTestCase {
|
|||||||
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api)
|
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api)
|
||||||
viewModel.updateSearchText("图")
|
viewModel.updateSearchText("图")
|
||||||
await viewModel.chooseFilterType(.image, api: api)
|
await viewModel.chooseFilterType(.image, api: api)
|
||||||
|
await viewModel.chooseSortType(.createdAscending, api: api)
|
||||||
_ = await viewModel.handleFileTap(folder(id: 9), api: api)
|
_ = await viewModel.handleFileTap(folder(id: 9), api: api)
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.files.map(\.id), [])
|
XCTAssertEqual(viewModel.files.map(\.id), [])
|
||||||
XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 9])
|
XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 9])
|
||||||
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1, 1])
|
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1, 1, 1])
|
||||||
XCTAssertEqual(api.listCalls[2].name, "图")
|
XCTAssertEqual(api.listCalls[2].name, "图")
|
||||||
XCTAssertEqual(api.listCalls[2].type, 2)
|
XCTAssertEqual(api.listCalls[2].type, 2)
|
||||||
|
XCTAssertEqual(api.listCalls[3].orderBy, 1)
|
||||||
|
XCTAssertEqual(api.listCalls[4].name, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBackRestoresCachedRootAndGoHome() async {
|
func testBackRestoresCachedRootAndGoHome() async {
|
||||||
@ -50,6 +88,58 @@ final class CloudDriveViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(api.listCalls.count, 2)
|
XCTAssertEqual(api.listCalls.count, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRootBackReturnsFalseWithoutRequest() async {
|
||||||
|
let api = MockCloudDriveAPI()
|
||||||
|
let viewModel = CloudDriveListViewModel()
|
||||||
|
|
||||||
|
let handled = await viewModel.navigateBack(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(handled)
|
||||||
|
XCTAssertEqual(viewModel.pathStack.map(\.id), [0])
|
||||||
|
XCTAssertTrue(api.listCalls.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBreadcrumbAndGoHomeRestoreFolderCaches() async {
|
||||||
|
let api = MockCloudDriveAPI()
|
||||||
|
api.listResponses = [
|
||||||
|
CloudFileListResponse(total: 1, list: [folder(id: 2)]),
|
||||||
|
CloudFileListResponse(total: 1, list: [folder(id: 3)]),
|
||||||
|
CloudFileListResponse(total: 1, list: [file(id: 9)]),
|
||||||
|
]
|
||||||
|
let viewModel = CloudDriveListViewModel()
|
||||||
|
|
||||||
|
await viewModel.refresh(api: api)
|
||||||
|
_ = await viewModel.handleFileTap(folder(id: 2), api: api)
|
||||||
|
_ = await viewModel.handleFileTap(folder(id: 3), api: api)
|
||||||
|
await viewModel.navigateToPathIndex(1, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 2])
|
||||||
|
XCTAssertEqual(viewModel.files.map(\.id), [3])
|
||||||
|
XCTAssertEqual(api.listCalls.count, 3)
|
||||||
|
|
||||||
|
await viewModel.goHome(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.pathStack.map(\.id), [0])
|
||||||
|
XCTAssertEqual(viewModel.files.map(\.id), [2])
|
||||||
|
XCTAssertEqual(api.listCalls.count, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFolderDepthLimitPreventsSixthLevel() async {
|
||||||
|
let api = MockCloudDriveAPI()
|
||||||
|
let viewModel = CloudDriveListViewModel()
|
||||||
|
var message: String?
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
for id in 1 ... 4 {
|
||||||
|
_ = await viewModel.handleFileTap(folder(id: id), api: api)
|
||||||
|
}
|
||||||
|
await viewModel.addFolder(name: "第六层", api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.pathStack.count, 5)
|
||||||
|
XCTAssertEqual(message, "不能创建更深的文件夹")
|
||||||
|
XCTAssertTrue(api.createFolderCalls.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
func testCreateDeleteMoveAndRenameRefresh() async {
|
func testCreateDeleteMoveAndRenameRefresh() async {
|
||||||
let api = MockCloudDriveAPI()
|
let api = MockCloudDriveAPI()
|
||||||
api.listResponses = Array(repeating: CloudFileListResponse(), count: 5)
|
api.listResponses = Array(repeating: CloudFileListResponse(), count: 5)
|
||||||
@ -83,6 +173,38 @@ final class CloudDriveViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(message, "空间不足")
|
XCTAssertEqual(message, "空间不足")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testEmptyFolderAndRenameNamesAreRejected() async {
|
||||||
|
let api = MockCloudDriveAPI()
|
||||||
|
let viewModel = CloudDriveListViewModel()
|
||||||
|
var messages: [String] = []
|
||||||
|
viewModel.onShowMessage = { messages.append($0) }
|
||||||
|
|
||||||
|
await viewModel.addFolder(name: " ", api: api)
|
||||||
|
viewModel.setControlFile(file(id: 7))
|
||||||
|
await viewModel.saveCurrentFileName("\n", api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(messages, ["名称不能为空", "名称不能为空"])
|
||||||
|
XCTAssertTrue(api.createFolderCalls.isEmpty)
|
||||||
|
XCTAssertTrue(api.renameFileCalls.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadCompletionRefreshesOnlyCurrentFolder() async {
|
||||||
|
let api = MockCloudDriveAPI()
|
||||||
|
api.listResponses = [
|
||||||
|
CloudFileListResponse(total: 1, list: [file(id: 1)]),
|
||||||
|
CloudFileListResponse(total: 1, list: [file(id: 2)]),
|
||||||
|
]
|
||||||
|
let viewModel = CloudDriveListViewModel()
|
||||||
|
|
||||||
|
await viewModel.refresh(api: api)
|
||||||
|
await viewModel.handleUploadCompleted(parentFolderId: 9, api: api)
|
||||||
|
XCTAssertEqual(api.listCalls.count, 1)
|
||||||
|
|
||||||
|
await viewModel.handleUploadCompleted(parentFolderId: 0, api: api)
|
||||||
|
XCTAssertEqual(api.listCalls.count, 2)
|
||||||
|
XCTAssertEqual(viewModel.files.map(\.id), [2])
|
||||||
|
}
|
||||||
|
|
||||||
private func file(id: Int, type: Int = 2) -> CloudFile {
|
private func file(id: Int, type: Int = 2) -> CloudFile {
|
||||||
CloudFile(id: id, fileUrl: "https://cdn/\(id).jpg", name: "文件\(id)", type: type)
|
CloudFile(id: id, fileUrl: "https://cdn/\(id).jpg", name: "文件\(id)", type: type)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,104 @@ final class CloudTransferManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(manager.downloadTasks.isEmpty)
|
XCTAssertTrue(manager.downloadTasks.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUploadPauseThenResumeCompletes() async throws {
|
||||||
|
let uploader = PausingThenSucceedCloudUploader(url: "https://cdn/resumed.jpg")
|
||||||
|
let api = TransferMockCloudDriveAPI()
|
||||||
|
let manager = CloudTransferManager(
|
||||||
|
store: MemoryTransferStore(),
|
||||||
|
uploader: uploader,
|
||||||
|
api: api,
|
||||||
|
downloader: FakeDownloader(),
|
||||||
|
photoSaver: FakePhotoSaver(),
|
||||||
|
scenicIdProvider: { 10 }
|
||||||
|
)
|
||||||
|
let fileURL = try makeTempFile(name: "resume.jpg")
|
||||||
|
|
||||||
|
manager.addUploadTask(localFileURL: fileURL, fileName: "resume.jpg", fileType: 2, fileSize: 3, parentFolderId: 7)
|
||||||
|
let id = try XCTUnwrap(manager.uploadTasks.first?.id)
|
||||||
|
try await waitUntil { uploader.attempts == 1 }
|
||||||
|
manager.pauseUpload(id: id)
|
||||||
|
try await waitUntil { manager.uploadTasks.first?.status == .paused }
|
||||||
|
|
||||||
|
manager.resumeUpload(id: id)
|
||||||
|
try await waitUntil { manager.uploadTasks.isEmpty }
|
||||||
|
|
||||||
|
XCTAssertEqual(uploader.attempts, 2)
|
||||||
|
XCTAssertEqual(api.uploadCalls.first?.parentFolderId, 7)
|
||||||
|
XCTAssertEqual(api.uploadCalls.first?.fileUrl, "https://cdn/resumed.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedUploadCanRetry() async throws {
|
||||||
|
let uploader = FailOnceCloudUploader(url: "https://cdn/retried.jpg")
|
||||||
|
let api = TransferMockCloudDriveAPI()
|
||||||
|
let manager = CloudTransferManager(
|
||||||
|
store: MemoryTransferStore(),
|
||||||
|
uploader: uploader,
|
||||||
|
api: api,
|
||||||
|
downloader: FakeDownloader(),
|
||||||
|
photoSaver: FakePhotoSaver(),
|
||||||
|
scenicIdProvider: { 10 }
|
||||||
|
)
|
||||||
|
let fileURL = try makeTempFile(name: "retry.jpg")
|
||||||
|
|
||||||
|
manager.addUploadTask(localFileURL: fileURL, fileName: "retry.jpg", fileType: 2, fileSize: 3, parentFolderId: 0)
|
||||||
|
try await waitUntil { manager.uploadTasks.first?.status == .failed }
|
||||||
|
let id = try XCTUnwrap(manager.uploadTasks.first?.id)
|
||||||
|
|
||||||
|
manager.resumeUpload(id: id)
|
||||||
|
try await waitUntil { manager.uploadTasks.isEmpty }
|
||||||
|
|
||||||
|
XCTAssertEqual(uploader.attempts, 2)
|
||||||
|
XCTAssertEqual(api.uploadCalls.first?.fileUrl, "https://cdn/retried.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadCancelRemovesTaskAndStagedFile() async throws {
|
||||||
|
let uploader = PausingThenSucceedCloudUploader(url: "unused")
|
||||||
|
let manager = CloudTransferManager(
|
||||||
|
store: MemoryTransferStore(),
|
||||||
|
uploader: uploader,
|
||||||
|
api: TransferMockCloudDriveAPI(),
|
||||||
|
downloader: FakeDownloader(),
|
||||||
|
photoSaver: FakePhotoSaver(),
|
||||||
|
scenicIdProvider: { 10 }
|
||||||
|
)
|
||||||
|
let fileURL = try makeTempFile(name: "cancel.jpg")
|
||||||
|
|
||||||
|
manager.addUploadTask(localFileURL: fileURL, fileName: "cancel.jpg", fileType: 2, fileSize: 3, parentFolderId: 0)
|
||||||
|
let id = try XCTUnwrap(manager.uploadTasks.first?.id)
|
||||||
|
manager.cancelUpload(id: id)
|
||||||
|
|
||||||
|
XCTAssertTrue(manager.uploadTasks.isEmpty)
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDownloadPauseThenResumeCompletes() async throws {
|
||||||
|
let downloader = PausingThenSucceedDownloader()
|
||||||
|
let saver = FakePhotoSaver()
|
||||||
|
let manager = CloudTransferManager(
|
||||||
|
store: MemoryTransferStore(),
|
||||||
|
uploader: FakeCloudUploader(url: "unused"),
|
||||||
|
api: TransferMockCloudDriveAPI(),
|
||||||
|
downloader: downloader,
|
||||||
|
photoSaver: saver,
|
||||||
|
scenicIdProvider: { 10 }
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.addDownloadTask(fileURL: "https://cdn/resume.jpg", fileName: "resume.jpg", fileType: 2)
|
||||||
|
let id = try XCTUnwrap(manager.downloadTasks.first?.id)
|
||||||
|
try await waitUntil { await downloader.attemptCount() == 1 }
|
||||||
|
manager.pauseDownload(id: id)
|
||||||
|
try await waitUntil { manager.downloadTasks.first?.status == .paused }
|
||||||
|
|
||||||
|
manager.resumeDownload(id: id)
|
||||||
|
try await waitUntil { manager.downloadTasks.first?.status == .completed }
|
||||||
|
|
||||||
|
let attemptCount = await downloader.attemptCount()
|
||||||
|
XCTAssertEqual(attemptCount, 2)
|
||||||
|
XCTAssertEqual(saver.saved.count, 1)
|
||||||
|
XCTAssertEqual(manager.downloadTasks.first?.progress, 100)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeTempFile(name: String) throws -> URL {
|
private func makeTempFile(name: String) throws -> URL {
|
||||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + name)
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + name)
|
||||||
try Data([1, 2, 3]).write(to: url)
|
try Data([1, 2, 3]).write(to: url)
|
||||||
@ -96,6 +194,20 @@ final class CloudTransferManagerTests: XCTestCase {
|
|||||||
try await Task.sleep(nanoseconds: 20_000_000)
|
try await Task.sleep(nanoseconds: 20_000_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func waitUntil(
|
||||||
|
timeout: TimeInterval = 2,
|
||||||
|
condition: @escaping () async -> Bool
|
||||||
|
) async throws {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while !(await condition()) {
|
||||||
|
if Date() > deadline {
|
||||||
|
XCTFail("Timed out waiting for condition")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class MemoryTransferStore: CloudTransferTaskStoring {
|
private final class MemoryTransferStore: CloudTransferTaskStoring {
|
||||||
@ -130,6 +242,44 @@ private final class FakeCloudUploader: CloudDriveUploading {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class PausingThenSucceedCloudUploader: CloudDriveUploading {
|
||||||
|
let url: String
|
||||||
|
private(set) var attempts = 0
|
||||||
|
|
||||||
|
init(url: String) {
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadCloudDriveFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1 {
|
||||||
|
try await Task.sleep(nanoseconds: 5_000_000_000)
|
||||||
|
}
|
||||||
|
onProgress(100)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class FailOnceCloudUploader: CloudDriveUploading {
|
||||||
|
let url: String
|
||||||
|
private(set) var attempts = 0
|
||||||
|
|
||||||
|
init(url: String) {
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadCloudDriveFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1 {
|
||||||
|
throw TestError.failed
|
||||||
|
}
|
||||||
|
onProgress(100)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class FakeDownloader: CloudFileDownloading {
|
private final class FakeDownloader: CloudFileDownloading {
|
||||||
var downloadCalls: [(remoteURL: String, fileName: String)] = []
|
var downloadCalls: [(remoteURL: String, fileName: String)] = []
|
||||||
|
|
||||||
@ -143,6 +293,38 @@ private final class FakeDownloader: CloudFileDownloading {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class PausingThenSucceedDownloader: CloudFileDownloading {
|
||||||
|
private let counter = DownloadAttemptCounter()
|
||||||
|
|
||||||
|
func downloadFile(remoteURL: String, fileName: String, onProgress: @escaping @Sendable (Int) -> Void) async throws -> URL {
|
||||||
|
let attempt = await counter.next()
|
||||||
|
if attempt == 1 {
|
||||||
|
try await Task.sleep(nanoseconds: 5_000_000_000)
|
||||||
|
}
|
||||||
|
onProgress(100)
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName)
|
||||||
|
try Data([1]).write(to: url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func attemptCount() async -> Int {
|
||||||
|
await counter.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor DownloadAttemptCounter {
|
||||||
|
private var value = 0
|
||||||
|
|
||||||
|
func next() -> Int {
|
||||||
|
value += 1
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func current() -> Int {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class FakePhotoSaver: CloudPhotoSaving {
|
private final class FakePhotoSaver: CloudPhotoSaving {
|
||||||
var saved: [(fileURL: URL, fileType: Int)] = []
|
var saved: [(fileURL: URL, fileType: Int)] = []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user