同步相册云盘界面并完善相关交互

This commit is contained in:
2026-07-10 17:15:15 +08:00
parent ab5220e460
commit ceca780ab3
13 changed files with 2269 additions and 532 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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>

View File

@ -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) }
} }
} }

View File

@ -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)
} }
} }

View File

@ -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
)
}
} }

View File

@ -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

View File

@ -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)
}
} }

View File

@ -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)
} }
} }

View File

@ -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 {

View File

@ -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)
} }

View File

@ -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)] = []