From ceca780ab34fd6525a3ae7b8a1aab3f91e5ea082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 10 Jul 2026 17:15:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=9B=B8=E5=86=8C=E4=BA=91?= =?UTF-8?q?=E7=9B=98=E7=95=8C=E9=9D=A2=E5=B9=B6=E5=AE=8C=E5=96=84=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/CloudTransferManager.swift | 37 +- .../ViewModels/CloudDriveListViewModel.swift | 23 +- suixinkan/Info.plist | 4 + .../CloudDriveActionSheetViewController.swift | 646 +++++++++++++-- .../CloudDriveDetailSheetViewController.swift | 311 +++++-- .../UI/CloudDrive/CloudDriveFileCell.swift | 175 ++-- .../UI/CloudDrive/CloudDriveFormatter.swift | 29 +- .../CloudDriveListViewController.swift | 762 +++++++++++++----- .../CloudDrivePreviewViewController.swift | 78 +- .../CloudDriveTransferViewController.swift | 333 ++++++-- .../WildReportRiskMapViewController.swift | 97 ++- suixinkanTests/CloudDriveViewModelTests.swift | 124 ++- .../CloudTransferManagerTests.swift | 182 +++++ 13 files changed, 2269 insertions(+), 532 deletions(-) diff --git a/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift b/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift index bd295cb..7ffb546 100644 --- a/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift +++ b/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift @@ -31,6 +31,7 @@ final class CloudTransferManager { private let photoSaver: any CloudPhotoSaving private let scenicIdProvider: () -> Int private var runningTasks: [String: Task] = [:] + private var operationGenerations: [String: Int] = [:] /// 初始化传输管理器。 init( @@ -95,6 +96,7 @@ final class CloudTransferManager { func pauseUpload(id: String) { runningTasks[id]?.cancel() runningTasks[id] = nil + invalidateOperation(id: id) updateUploadTask(id: id) { task in task.status = .paused task.updatedAt = Date().timeIntervalSince1970 @@ -110,6 +112,7 @@ final class CloudTransferManager { func cancelUpload(id: String) { runningTasks[id]?.cancel() runningTasks[id] = nil + operationGenerations[id] = nil if let task = uploadTasks.first(where: { $0.id == id }), !task.localPath.isEmpty { try? FileManager.default.removeItem(atPath: task.localPath) } @@ -121,6 +124,7 @@ final class CloudTransferManager { func pauseDownload(id: String) { runningTasks[id]?.cancel() runningTasks[id] = nil + invalidateOperation(id: id) updateDownloadTask(id: id) { task in task.status = .paused task.updatedAt = Date().timeIntervalSince1970 @@ -136,6 +140,7 @@ final class CloudTransferManager { func cancelDownload(id: String) { runningTasks[id]?.cancel() runningTasks[id] = nil + operationGenerations[id] = nil downloadTasks.removeAll { $0.id == id } persistAndNotify() } @@ -149,6 +154,7 @@ final class CloudTransferManager { uploadTasks[index].progress = max(uploadTasks[index].progress, 1) uploadTasks[index].updatedAt = Date().timeIntervalSince1970 let seed = uploadTasks[index] + let generation = nextOperationGeneration(id: id) persistAndNotify() runningTasks[id] = Task { [weak self] in @@ -166,24 +172,30 @@ final class CloudTransferManager { scenicId: scenicIdProvider() ) { [weak self] progress 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() + guard isCurrentOperation(id: id, generation: generation) else { return } try await api.uploadCloudFile( parentFolderId: seed.parentFolderId, fileUrl: uploadedURL, fileName: seed.fileName ) + guard isCurrentOperation(id: id, generation: generation) else { return } try? FileManager.default.removeItem(atPath: seed.localPath) runningTasks[id] = nil + operationGenerations[id] = nil uploadTasks.removeAll { $0.id == id } persistAndNotify() onUploadCompleted?(seed.parentFolderId) } catch is CancellationError { + guard isCurrentOperation(id: id, generation: generation) else { return } runningTasks[id] = nil markUploadPaused(id: id) } catch { + guard isCurrentOperation(id: id, generation: generation) else { return } runningTasks[id] = nil markUploadFailed(id: id, message: error.localizedDescription) } @@ -199,6 +211,7 @@ final class CloudTransferManager { downloadTasks[index].progress = max(downloadTasks[index].progress, 1) downloadTasks[index].updatedAt = Date().timeIntervalSince1970 let seed = downloadTasks[index] + let generation = nextOperationGeneration(id: id) persistAndNotify() runningTasks[id] = Task { [weak self] in @@ -209,22 +222,28 @@ final class CloudTransferManager { fileName: seed.fileName ) { [weak self] progress 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() + guard isCurrentOperation(id: id, generation: generation) else { return } try await photoSaver.saveToPhotoLibrary(fileURL: localURL, fileType: seed.fileType) + guard isCurrentOperation(id: id, generation: generation) else { return } try? FileManager.default.removeItem(at: localURL) runningTasks[id] = nil + operationGenerations[id] = nil updateDownloadTask(id: id) { task in task.status = .completed task.progress = 100 task.updatedAt = Date().timeIntervalSince1970 } } catch is CancellationError { + guard isCurrentOperation(id: id, generation: generation) else { return } runningTasks[id] = nil markDownloadPaused(id: id) } catch { + guard isCurrentOperation(id: id, generation: generation) else { return } runningTasks[id] = nil markDownloadFailed(id: id, message: error.localizedDescription) } @@ -291,6 +310,20 @@ final class CloudTransferManager { store.saveTasks(uploadTasks + downloadTasks) 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 { diff --git a/suixinkan/Features/CloudDrive/ViewModels/CloudDriveListViewModel.swift b/suixinkan/Features/CloudDrive/ViewModels/CloudDriveListViewModel.swift index 504f424..3cbef59 100644 --- a/suixinkan/Features/CloudDrive/ViewModels/CloudDriveListViewModel.swift +++ b/suixinkan/Features/CloudDrive/ViewModels/CloudDriveListViewModel.swift @@ -34,11 +34,14 @@ enum CloudDriveFilterType: Int, CaseIterable, Sendable { case image = 2 case folder = 99 - /// Android 对齐文案。 - var title: String { + /// Android 下拉菜单顺序。 + static let androidMenuOrder: [CloudDriveFilterType] = [.all, .image, .video, .folder] + + /// Android 下拉菜单文案。 + var menuTitle: String { switch self { case .all: - "全部类型" + "全部" case .video: "视频" 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 { diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index a778307..9d26caa 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -20,6 +20,10 @@ weixin weixinULAPI weixinURLParamsAPI + iosamap + baidumap + qqmap + comgooglemaps UIApplicationSceneManifest diff --git a/suixinkan/UI/CloudDrive/CloudDriveActionSheetViewController.swift b/suixinkan/UI/CloudDrive/CloudDriveActionSheetViewController.swift index 7cccdd0..812a8ba 100644 --- a/suixinkan/UI/CloudDrive/CloudDriveActionSheetViewController.swift +++ b/suixinkan/UI/CloudDrive/CloudDriveActionSheetViewController.swift @@ -6,8 +6,14 @@ import SnapKit import UIKit -/// 云盘底部操作弹层,用于同步 Android 云盘上传与文件操作菜单。 +/// 云盘底部操作弹层,严格同步 Android 上传与文件操作菜单。 final class CloudDriveActionSheetViewController: UIViewController { + /// 云盘弹层内容布局。 + enum Layout { + case upload + case controls + } + /// 云盘底部弹层操作项。 struct Item { /// 操作文案。 @@ -20,19 +26,20 @@ final class CloudDriveActionSheetViewController: UIViewController { let handler: () -> Void } - private let sheetTitle: String? + private let sheetTitle: String + private let layout: Layout private let items: [Item] private let dimView = UIView() private let panelView = UIView() - private let stackView = UIStackView() + private let contentStack = UIStackView() - /// 初始化云盘底部弹层。 - init(title: String? = nil, items: [Item]) { - self.sheetTitle = title + /// 初始化 Android 风格云盘底部弹层。 + init(title: String, layout: Layout, items: [Item]) { + sheetTitle = title + self.layout = layout self.items = items super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen - modalTransitionStyle = .crossDissolve } @available(*, unavailable) @@ -46,92 +53,607 @@ final class CloudDriveActionSheetViewController: UIViewController { 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() { view.backgroundColor = .clear dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35) dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) panelView.backgroundColor = .white - panelView.layer.cornerRadius = 18 + panelView.layer.cornerRadius = 16 panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] panelView.clipsToBounds = true - stackView.axis = .vertical - stackView.spacing = 0 + contentStack.axis = .vertical + contentStack.spacing = 0 view.addSubview(dimView) view.addSubview(panelView) - panelView.addSubview(stackView) + panelView.addSubview(contentStack) - if let sheetTitle { - let titleLabel = UILabel() - titleLabel.text = sheetTitle - titleLabel.font = .app(.bodyMedium) - titleLabel.textColor = AppColor.textPrimary - titleLabel.textAlignment = .center - titleLabel.snp.makeConstraints { make in - make.height.equalTo(54) - } - stackView.addArrangedSubview(titleLabel) + let titleContainer = UIView() + let titleLabel = UILabel() + titleLabel.text = sheetTitle + titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) + titleLabel.textColor = .black + titleLabel.textAlignment = .left + titleContainer.addSubview(titleLabel) + titleContainer.snp.makeConstraints { make in make.height.equalTo(52) } + titleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.centerY.equalToSuperview() + } + 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 { - stackView.addArrangedSubview(makeItemButton(item)) - } - - let spacer = UIView() - spacer.backgroundColor = AppColor.pageBackground - spacer.snp.makeConstraints { make in - make.height.equalTo(8) - } - stackView.addArrangedSubview(spacer) - - let cancelButton = UIButton(type: .system) - cancelButton.setTitle("取消", for: .normal) - cancelButton.setTitleColor(AppColor.textPrimary, for: .normal) - cancelButton.titleLabel?.font = .app(.body) - cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) - cancelButton.snp.makeConstraints { make in - make.height.equalTo(54) - } - stackView.addArrangedSubview(cancelButton) + let divider = UIView() + divider.backgroundColor = UIColor(hex: 0xEEEEEE) + divider.snp.makeConstraints { make in make.height.equalTo(0.5) } + contentStack.addArrangedSubview(divider) + contentStack.addArrangedSubview(makeCancelArea()) + let bottomSpacer = UIView() + bottomSpacer.snp.makeConstraints { make in make.height.equalTo(12) } + contentStack.addArrangedSubview(bottomSpacer) } private func setupConstraints() { - dimView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } + dimView.snp.makeConstraints { make in make.edges.equalToSuperview() } panelView.snp.makeConstraints { make in 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.bottom.equalTo(view.safeAreaLayoutGuide) } } - private func makeItemButton(_ item: Item) -> UIButton { - let button = UIButton(type: .system) - button.setTitle(item.title, for: .normal) - button.setImage(item.image, for: .normal) - button.tintColor = item.isDestructive ? AppColor.danger : AppColor.textPrimary - button.setTitleColor(item.isDestructive ? AppColor.danger : AppColor.textPrimary, for: .normal) - button.titleLabel?.font = .app(.body) - button.contentHorizontalAlignment = .left - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.xl, bottom: 0, right: AppSpacing.xl) - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.md) - button.addAction(UIAction { [weak self] _ in - self?.dismiss(animated: true) { - item.handler() + 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) } - }, for: .touchUpInside) - button.snp.makeConstraints { make in - make.height.equalTo(54) + 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) + button.setTitle("取消", for: .normal) + button.setTitleColor(UIColor(hex: 0x4B5563), for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + button.backgroundColor = UIColor(hex: 0xF4F4F4) + button.layer.cornerRadius = 12 + button.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + container.addSubview(button) + button.snp.makeConstraints { make in + 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 } @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) } } } diff --git a/suixinkan/UI/CloudDrive/CloudDriveDetailSheetViewController.swift b/suixinkan/UI/CloudDrive/CloudDriveDetailSheetViewController.swift index f4579ea..7d4ac66 100644 --- a/suixinkan/UI/CloudDrive/CloudDriveDetailSheetViewController.swift +++ b/suixinkan/UI/CloudDrive/CloudDriveDetailSheetViewController.swift @@ -6,18 +6,15 @@ import SnapKit import UIKit -/// 云盘文件详情底部表单,用于查看文件信息并修改名称。 +/// 云盘文件详情底部表单,严格对齐 Android `CloudStorageFileDetailBottomModal`。 final class CloudDriveDetailSheetViewController: UIViewController { private let file: CloudFile private let onSave: (String) -> Void private let dimView = UIView() private let panelView = UIView() - private let titleLabel = UILabel() - private let nameTitleLabel = UILabel() + private let scrollView = UIScrollView() + private let contentView = UIView() 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) { @@ -25,7 +22,6 @@ final class CloudDriveDetailSheetViewController: UIViewController { self.onSave = onSave super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen - modalTransitionStyle = .crossDissolve } @available(*, unavailable) @@ -37,6 +33,25 @@ final class CloudDriveDetailSheetViewController: UIViewController { super.viewDidLoad() setupUI() 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() { @@ -45,99 +60,241 @@ final class CloudDriveDetailSheetViewController: UIViewController { dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) panelView.backgroundColor = .white - panelView.layer.cornerRadius = 18 + panelView.layer.cornerRadius = 28 panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] panelView.clipsToBounds = true - - titleLabel.text = "详情" - titleLabel.font = .app(.bodyMedium) - titleLabel.textColor = AppColor.textPrimary - titleLabel.textAlignment = .center - - nameTitleLabel.text = "文件名称" - nameTitleLabel.font = .app(.caption) - nameTitleLabel.textColor = AppColor.textSecondary - - nameField.text = file.name - nameField.font = .app(.body) - nameField.textColor = AppColor.textPrimary - nameField.clearButtonMode = .whileEditing - nameField.returnKeyType = .done - nameField.delegate = self - nameField.backgroundColor = AppColor.inputBackground - nameField.layer.cornerRadius = AppRadius.xs - nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1)) - nameField.leftViewMode = .always - - infoLabel.text = "\(file.createdAt) | \(CloudDriveFormatter.fileSize(file.fileSize))\n类型:\(CloudDriveFormatter.typeName(file.type))" - infoLabel.font = .app(.caption) - infoLabel.textColor = AppColor.textTertiary - infoLabel.numberOfLines = 0 - - cancelButton.setTitle("取消", for: .normal) - cancelButton.setTitleColor(AppColor.textSecondary, for: .normal) - cancelButton.titleLabel?.font = .app(.body) - cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) - - saveButton.setTitle("保存", for: .normal) - saveButton.setTitleColor(.white, for: .normal) - saveButton.titleLabel?.font = .app(.bodyMedium) - saveButton.backgroundColor = AppColor.primary - saveButton.layer.cornerRadius = AppRadius.xs - saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) + scrollView.alwaysBounceVertical = false + scrollView.keyboardDismissMode = .interactive view.addSubview(dimView) 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() { - dimView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } + dimView.snp.makeConstraints { make in make.edges.equalToSuperview() } 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.height.equalTo(54) + make.bottom.equalTo(view.safeAreaLayoutGuide) } - nameTitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + contentView.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + 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) + } + + private func makeFieldTitle(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 13) + label.textColor = UIColor(hex: 0x7B8EAA) + return label + } + + private func makeInfoRow(label: String, value: String) -> UIView { + let container = UIView() + let labelView = makeFieldTitle(label) + let valueContainer = UIView() + 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() } - infoLabel.snp.makeConstraints { make in - make.top.equalTo(nameField.snp.bottom).offset(AppSpacing.md) - make.leading.trailing.equalTo(nameTitleLabel) + valueContainer.snp.makeConstraints { make in + make.top.equalTo(labelView.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview() + make.height.equalTo(36) + make.bottom.equalToSuperview().inset(6) } - cancelButton.snp.makeConstraints { make in - make.top.equalTo(infoLabel.snp.bottom).offset(AppSpacing.lg) - make.leading.equalToSuperview().offset(AppSpacing.md) - make.height.equalTo(44) - make.width.equalTo(saveButton) - make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md) + valueLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() } - saveButton.snp.makeConstraints { make in - make.top.bottom.width.equalTo(cancelButton) - make.leading.equalTo(cancelButton.snp.trailing).offset(AppSpacing.md) - make.trailing.equalToSuperview().inset(AppSpacing.md) + 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() { - dismiss(animated: true) + close() } @objc private func saveTapped() { - let name = nameField.text ?? "" - dismiss(animated: true) { [onSave] in - onSave(name) - } + let name = (nameField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + close { [onSave] in 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) } } diff --git a/suixinkan/UI/CloudDrive/CloudDriveFileCell.swift b/suixinkan/UI/CloudDrive/CloudDriveFileCell.swift index c2306e2..5de366e 100644 --- a/suixinkan/UI/CloudDrive/CloudDriveFileCell.swift +++ b/suixinkan/UI/CloudDrive/CloudDriveFileCell.swift @@ -7,15 +7,20 @@ import Kingfisher import SnapKit import UIKit -/// 云盘文件 Cell,支持 Android 宫格与列表两种样式。 +/// 云盘文件 Cell,严格对齐 Android 宫格与列表两种样式。 final class CloudDriveFileCell: UICollectionViewCell { static let reuseIdentifier = "CloudDriveFileCell" + /// 点击文件操作入口时回调。 var onDetails: (() -> Void)? private let thumbnailView = UIImageView() - private let folderIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_folder_type", fallbackSystemName: "folder.fill")) - private let playIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_play", fallbackSystemName: "play.circle.fill")) + private let folderIconView = UIImageView( + image: CloudDriveAsset.image(named: "icon_cloud_storage_folder_type", fallbackSystemName: "folder.fill") + ) + private let playIconView = UIImageView( + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_play", fallbackSystemName: "play.circle.fill") + ) private let extensionLabel = CloudDrivePaddingLabel() private let nameLabel = UILabel() private let detailLabel = UILabel() @@ -47,126 +52,151 @@ final class CloudDriveFileCell: UICollectionViewCell { /// 应用云盘文件数据。 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 - detailLabel.text = mode == .grid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file) - configureThumbnail(file) - gridConstraints.forEach { mode == .grid ? $0.activate() : $0.deactivate() } - listConstraints.forEach { mode == .list ? $0.activate() : $0.deactivate() } - contentView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0 - contentView.layer.borderWidth = mode == .grid ? 1 : 0 - contentView.backgroundColor = mode == .grid ? .white : .clear - thumbnailView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0 - nameLabel.textAlignment = mode == .grid ? .left : .left + nameLabel.font = .systemFont(ofSize: 15, weight: isGrid ? .semibold : .regular) + nameLabel.lineBreakMode = isGrid ? .byTruncatingTail : .byTruncatingMiddle + detailLabel.text = isGrid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file) + detailLabel.textColor = isGrid ? UIColor(hex: 0x4B5563) : UIColor(hex: 0x7B8EAA) + + contentView.layer.cornerRadius = isGrid ? 12 : 0 + contentView.layer.borderWidth = isGrid ? 1 : 0 + contentView.backgroundColor = isGrid ? .white : .clear + thumbnailView.layer.cornerRadius = isGrid ? 12 : 0 + thumbnailView.clipsToBounds = isGrid + + configureThumbnail(file, mode: mode) } private func setupUI() { + backgroundColor = .clear contentView.clipsToBounds = true contentView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor thumbnailView.contentMode = .scaleAspectFill thumbnailView.clipsToBounds = true - thumbnailView.backgroundColor = AppColor.warningBackground - folderIconView.tintColor = AppColor.warning folderIconView.contentMode = .scaleAspectFit + folderIconView.isHidden = true - playIconView.tintColor = .white playIconView.contentMode = .scaleAspectFit playIconView.isHidden = true - extensionLabel.font = .app(.caption) + extensionLabel.font = .systemFont(ofSize: 12, weight: .bold) extensionLabel.textColor = .white extensionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5) - extensionLabel.layer.cornerRadius = AppRadius.xs + extensionLabel.layer.cornerRadius = 4 extensionLabel.clipsToBounds = true extensionLabel.isHidden = true - nameLabel.font = .app(.body) - nameLabel.textColor = AppColor.textPrimary + nameLabel.textColor = UIColor(hex: 0x333333) nameLabel.numberOfLines = 1 - detailLabel.font = .app(.caption) - detailLabel.textColor = UIColor(hex: 0x4B5563) + detailLabel.font = .systemFont(ofSize: 12) detailLabel.numberOfLines = 1 - moreButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "ellipsis"), for: .normal) - moreButton.tintColor = AppColor.textSecondary + let detailsImage = CloudDriveAsset.resizedImage( + 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.accessibilityLabel = "文件操作" - contentView.addSubview(thumbnailView) - contentView.addSubview(folderIconView) - contentView.addSubview(playIconView) - contentView.addSubview(extensionLabel) - contentView.addSubview(nameLabel) - contentView.addSubview(detailLabel) - contentView.addSubview(moreButton) + [thumbnailView, folderIconView, playIconView, extensionLabel, nameLabel, detailLabel, moreButton] + .forEach(contentView.addSubview) } private func setupConstraints() { thumbnailView.snp.makeConstraints { make in gridConstraints.append(make.top.leading.trailing.equalToSuperview().constraint) - gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).multipliedBy(0.56).constraint) + gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).dividedBy(1.77).constraint) listConstraints.append(make.leading.centerY.equalToSuperview().constraint) listConstraints.append(make.width.height.equalTo(48).constraint) } folderIconView.snp.makeConstraints { make in make.center.equalTo(thumbnailView) - make.width.height.equalTo(42) + make.width.height.equalTo(48) } playIconView.snp.makeConstraints { make in make.center.equalTo(thumbnailView) - make.width.height.equalTo(28) + make.width.height.equalTo(24) } 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 - gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(AppSpacing.xs).constraint) - gridConstraints.append(make.leading.equalToSuperview().offset(AppSpacing.sm).constraint) - listConstraints.append(make.top.equalToSuperview().offset(AppSpacing.sm).constraint) - listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(AppSpacing.sm).constraint) - make.trailing.equalTo(moreButton.snp.leading).offset(-AppSpacing.xs) + gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(8).constraint) + gridConstraints.append(make.leading.equalToSuperview().offset(12).constraint) + listConstraints.append(make.top.equalToSuperview().offset(12).constraint) + listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(12).constraint) + make.trailing.equalTo(moreButton.snp.leading).offset(-4) } detailLabel.snp.makeConstraints { make in - make.top.equalTo(nameLabel.snp.bottom).offset(AppSpacing.xxs) - make.leading.equalTo(nameLabel) - make.trailing.equalTo(nameLabel) + make.top.equalTo(nameLabel.snp.bottom).offset(2) + make.leading.trailing.equalTo(nameLabel) } 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.width.height.equalTo(32) + make.width.height.equalTo(28) } listConstraints.forEach { $0.deactivate() } } - private func configureThumbnail(_ file: CloudFile) { - if file.isFolder { - thumbnailView.image = nil - thumbnailView.backgroundColor = AppColor.warningBackground - folderIconView.isHidden = false - extensionLabel.isHidden = true + private func configureThumbnail(_ file: CloudFile, mode: CloudDriveDisplayMode) { + thumbnailView.kf.cancelDownloadTask() + folderIconView.isHidden = true + playIconView.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 } - folderIconView.isHidden = true - thumbnailView.backgroundColor = AppColor.inputBackground - let preview = file.isVideo ? file.coverUrl : file.fileUrl - if let url = URL(string: preview), !preview.isEmpty { - let placeholder = CloudDriveAsset.image( - named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo", - fallbackSystemName: file.isVideo ? "video" : "photo" - ) + + thumbnailView.contentMode = .scaleAspectFill + if file.isFolder { + 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( + named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo", + fallbackSystemName: file.isVideo ? "video" : "photo" + ) + if let url = URL(string: previewURL), !previewURL.isEmpty { thumbnailView.kf.setImage(with: url, placeholder: placeholder) } else { - thumbnailView.image = CloudDriveAsset.image( - named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo", - fallbackSystemName: file.isVideo ? "video" : "photo" - ) - thumbnailView.tintColor = AppColor.textTertiary + thumbnailView.image = placeholder } playIconView.isHidden = !file.isVideo extensionLabel.text = CloudDriveFormatter.fileExtension(file.fileUrl.isEmpty ? file.name : file.fileUrl) - extensionLabel.isHidden = false + extensionLabel.isHidden = extensionLabel.text?.isEmpty ?? true } @objc private func moreTapped() { @@ -174,16 +204,19 @@ final class CloudDriveFileCell: UICollectionViewCell { } } -/// 带内边距的标签。 +/// 云盘文件扩展名标签,提供 Android 对齐的四边内边距。 private final class CloudDrivePaddingLabel: UILabel { - var insets = UIEdgeInsets(top: 3, left: 5, bottom: 3, right: 5) - - override var intrinsicContentSize: CGSize { - let size = super.intrinsicContentSize - return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) - } + private let insets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4) override func drawText(in rect: CGRect) { 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 + ) + } } diff --git a/suixinkan/UI/CloudDrive/CloudDriveFormatter.swift b/suixinkan/UI/CloudDrive/CloudDriveFormatter.swift index 2db76db..d8bb87a 100644 --- a/suixinkan/UI/CloudDrive/CloudDriveFormatter.swift +++ b/suixinkan/UI/CloudDrive/CloudDriveFormatter.swift @@ -24,15 +24,16 @@ enum CloudDriveFormatter { /// 文件描述。 static func fileDescription(_ file: CloudFile) -> String { + let createdDate = dateOnly(file.createdAt) 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 { - guard bytes > 0 else { return "0B" } + guard bytes > 0 else { return "0 B" } let units = ["B", "KB", "MB", "GB", "TB"] var value = Double(bytes) var index = 0 @@ -40,10 +41,17 @@ enum CloudDriveFormatter { value /= 1024 index += 1 } - if index == 0 { - return "\(Int(value))\(units[index])" + return String(format: "%.2f %@", 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[.. UIImage? { 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) + } } diff --git a/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift b/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift index 251b33e..d275963 100644 --- a/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift +++ b/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift @@ -3,38 +3,50 @@ // suixinkan // -import Kingfisher import PhotosUI import SnapKit import UIKit import UniformTypeIdentifiers -/// 相册云盘列表页,对齐 Android `CloudStorageListScreen`。 +/// 相册云盘列表页,严格对齐 Android `CloudStorageListScreen`。 final class CloudDriveListViewController: BaseViewController { private let viewModel = CloudDriveListViewModel() private let api: any CloudDriveServing private let transferManager: CloudTransferManager + private let navigationDivider = UIView() private let pathScrollView = UIScrollView() private let pathStackView = UIStackView() private let searchContainer = UIView() - private let searchIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_search", fallbackSystemName: "magnifyingglass")) + private let searchIconView = UIImageView( + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_search", fallbackSystemName: "magnifyingglass") + ) private let searchField = UITextField() - private let sortButton = UIButton(type: .system) - private let filterButton = UIButton(type: .system) + private let sortButton = CloudDriveMenuButton() + private let filterButton = CloudDriveMenuButton() private let displayModeButton = UIButton(type: .system) + private let contentDivider = UIView() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! - private let uploadButton = AppButton(title: "上传") + private let refreshControl = UIRefreshControl() + private let loadingMoreIndicator = UIActivityIndicatorView(style: .medium) + private let uploadButton = UIButton(type: .system) private let bottomBar = UIView() + private var appliedDisplayMode: CloudDriveDisplayMode? + private var lastInteractionTimes: [String: TimeInterval] = [:] + private var previousStandardAppearance: UINavigationBarAppearance? + private var previousScrollEdgeAppearance: UINavigationBarAppearance? + private var previousCompactAppearance: UINavigationBarAppearance? + private var previousNavigationTintColor: UIColor? + /// 初始化相册云盘列表页。 init( api: (any CloudDriveServing)? = nil, - transferManager: CloudTransferManager = .shared + transferManager: CloudTransferManager? = nil ) { self.api = api ?? NetworkServices.shared.cloudDriveAPI - self.transferManager = transferManager + self.transferManager = transferManager ?? .shared super.init(nibName: nil, bundle: nil) } @@ -44,74 +56,126 @@ final class CloudDriveListViewController: BaseViewController { } override func setupNavigationBar() { - title = "相册云盘" - navigationItem.rightBarButtonItem = UIBarButtonItem( - image: CloudDriveAsset.image(named: "icon_cloud_storage_transit", fallbackSystemName: "arrow.up.arrow.down.circle"), - style: .plain, - target: self, - action: #selector(openTransferManager) + let titleLabel = UILabel() + titleLabel.text = "相册云盘" + titleLabel.textColor = UIColor(hex: 0x333333) + titleLabel.font = .systemFont(ofSize: 18) + navigationItem.titleView = titleLabel + + let backButton = UIButton(type: .system) + backButton.setImage(UIImage(systemName: "chevron.left")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)), for: .normal) + backButton.tintColor = .black + backButton.accessibilityLabel = "返回" + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.snp.makeConstraints { make in + make.width.height.equalTo(44) + } + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton) + + let transferButton = UIButton(type: .system) + transferButton.setImage( + CloudDriveAsset.resizedImage( + named: "icon_cloud_storage_transit", + fallbackSystemName: "arrow.up.arrow.down.circle", + size: CGSize(width: 20, height: 20) + ), + for: .normal ) + transferButton.imageView?.contentMode = .scaleAspectFit + transferButton.accessibilityLabel = "传输管理" + transferButton.addTarget(self, action: #selector(openTransferManager), for: .touchUpInside) + transferButton.snp.makeConstraints { make in + make.width.height.equalTo(44) + } + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: transferButton) } override func setupUI() { view.backgroundColor = .white + navigationDivider.backgroundColor = UIColor(hex: 0xEEEEEE) pathScrollView.showsHorizontalScrollIndicator = false + pathScrollView.alwaysBounceHorizontal = false pathStackView.axis = .horizontal pathStackView.alignment = .center - pathStackView.spacing = AppSpacing.xs + pathStackView.spacing = 0 pathScrollView.addSubview(pathStackView) - searchContainer.backgroundColor = AppColor.inputBackground - searchContainer.layer.cornerRadius = AppRadius.xs - searchIconView.tintColor = AppColor.textTertiary - searchField.placeholder = "搜索文件名称" - searchField.font = .app(.body) - searchField.textColor = AppColor.textPrimary + searchContainer.backgroundColor = UIColor(hex: 0xF4F4F4) + searchContainer.layer.cornerRadius = 4 + searchIconView.contentMode = .scaleAspectFit + searchField.attributedPlaceholder = NSAttributedString( + string: "搜索文件名称", + attributes: [ + .font: UIFont.systemFont(ofSize: 14), + .foregroundColor: UIColor(hex: 0xB6BECA), + ] + ) + searchField.font = .systemFont(ofSize: 14) + searchField.textColor = UIColor(hex: 0x333333) searchField.returnKeyType = .search + searchField.clearButtonMode = .never searchField.delegate = self searchContainer.addSubview(searchIconView) searchContainer.addSubview(searchField) - configureFilterButton(sortButton) - configureFilterButton(filterButton) - displayModeButton.tintColor = AppColor.textPrimary + sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside) + filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside) displayModeButton.backgroundColor = .white - displayModeButton.layer.cornerRadius = AppRadius.xs + displayModeButton.imageView?.contentMode = .scaleAspectFit + displayModeButton.accessibilityLabel = "切换展示方式" + contentDivider.backgroundColor = UIColor(hex: 0xEEEEEE) collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = .white collectionView.alwaysBounceVertical = true collectionView.delegate = self + collectionView.refreshControl = refreshControl collectionView.register(CloudDriveFileCell.self, forCellWithReuseIdentifier: CloudDriveFileCell.reuseIdentifier) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, file in - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: CloudDriveFileCell.reuseIdentifier, - for: indexPath - ) as! CloudDriveFileCell - cell.apply(file: file, mode: self?.viewModel.displayMode ?? .grid) - cell.onDetails = { [weak self] in self?.showControlSheet(for: file) } + guard let self, + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: CloudDriveFileCell.reuseIdentifier, + for: indexPath + ) as? CloudDriveFileCell else { + return UICollectionViewCell() + } + cell.apply(file: file, mode: self.viewModel.displayMode) + cell.onDetails = { [weak self] in + guard let self, self.shouldHandleInteraction("details_\(file.id)") else { return } + self.showControlSheet(for: file) + } return cell } + loadingMoreIndicator.color = AppColor.primary + collectionView.addSubview(loadingMoreIndicator) + bottomBar.backgroundColor = .white - bottomBar.layer.borderColor = AppColor.border.cgColor + bottomBar.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor bottomBar.layer.borderWidth = 0.5 - view.addSubview(pathScrollView) - view.addSubview(searchContainer) - view.addSubview(sortButton) - view.addSubview(filterButton) - view.addSubview(displayModeButton) - view.addSubview(collectionView) - view.addSubview(bottomBar) + uploadButton.setTitle("上传", for: .normal) + uploadButton.setTitleColor(.white, for: .normal) + uploadButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + uploadButton.backgroundColor = AppColor.primary + uploadButton.layer.cornerRadius = 10 + uploadButton.accessibilityLabel = "上传" + + [navigationDivider, pathScrollView, searchContainer, sortButton, filterButton, displayModeButton, contentDivider, collectionView, bottomBar] + .forEach(view.addSubview) bottomBar.addSubview(uploadButton) } override func setupConstraints() { - pathScrollView.snp.makeConstraints { make in + navigationDivider.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.leading.trailing.equalToSuperview() + make.height.equalTo(0.5) + } + pathScrollView.snp.makeConstraints { make in + make.top.equalTo(navigationDivider.snp.bottom) + make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(52) } pathStackView.snp.makeConstraints { make in @@ -120,53 +184,67 @@ final class CloudDriveListViewController: BaseViewController { } searchContainer.snp.makeConstraints { make in make.top.equalTo(pathScrollView.snp.bottom).offset(2) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(36) } searchIconView.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(AppSpacing.sm) + make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.width.height.equalTo(16) } searchField.snp.makeConstraints { make in - make.leading.equalTo(searchIconView.snp.trailing).offset(AppSpacing.sm) - make.top.bottom.trailing.equalToSuperview().inset(AppSpacing.xs) + make.leading.equalTo(searchIconView.snp.trailing).offset(12) + make.top.bottom.equalToSuperview() + make.trailing.equalToSuperview().inset(8) } sortButton.snp.makeConstraints { make in - make.top.equalTo(searchContainer.snp.bottom).offset(AppSpacing.md) - make.leading.equalToSuperview().offset(AppSpacing.md) + make.top.equalTo(searchContainer.snp.bottom).offset(16) + make.leading.equalToSuperview().offset(16) make.height.equalTo(36) } filterButton.snp.makeConstraints { make in make.top.width.height.equalTo(sortButton) - make.leading.equalTo(sortButton.snp.trailing).offset(AppSpacing.sm) + make.leading.equalTo(sortButton.snp.trailing).offset(12) } displayModeButton.snp.makeConstraints { make in make.centerY.equalTo(sortButton) - make.leading.equalTo(filterButton.snp.trailing).offset(AppSpacing.sm) + make.leading.equalTo(filterButton.snp.trailing).offset(12) make.trailing.equalToSuperview().inset(6) - make.width.height.equalTo(36) + make.width.height.equalTo(35) } sortButton.snp.makeConstraints { make in make.width.equalTo(filterButton) } + contentDivider.snp.makeConstraints { make in + make.top.equalTo(sortButton.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview() + make.height.equalTo(0.5) + } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } uploadButton.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md) + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(48) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } collectionView.snp.makeConstraints { make in - make.top.equalTo(sortButton.snp.bottom).offset(AppSpacing.md) + make.top.equalTo(contentDivider.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } + loadingMoreIndicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(collectionView.frameLayoutGuide).inset(12) + } } override func bindActions() { uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside) + refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) + viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } @@ -180,7 +258,6 @@ final class CloudDriveListViewController: BaseViewController { transferManager.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } - configureMenus() } override func viewDidLoad() { @@ -188,117 +265,184 @@ final class CloudDriveListViewController: BaseViewController { Task { await viewModel.refresh(api: api, showLoading: true) } } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + applyCloudNavigationBarAppearance() + updateInteractivePopGesture() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + restoreNavigationBarAppearance() + navigationController?.interactivePopGestureRecognizer?.isEnabled = true + } + + private func applyCloudNavigationBarAppearance() { + guard let navigationBar = navigationController?.navigationBar else { return } + previousStandardAppearance = navigationBar.standardAppearance + previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance + previousCompactAppearance = navigationBar.compactAppearance + previousNavigationTintColor = navigationBar.tintColor + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .white + appearance.shadowColor = .clear + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.tintColor = .black + } + + private func restoreNavigationBarAppearance() { + guard let navigationBar = navigationController?.navigationBar else { return } + if let previousStandardAppearance { + navigationBar.standardAppearance = previousStandardAppearance + } + navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance + navigationBar.compactAppearance = previousCompactAppearance + navigationBar.tintColor = previousNavigationTintColor + } + @MainActor private func applyViewModel() { rebuildBreadcrumb() searchField.text = viewModel.searchText - sortButton.setTitle(viewModel.sortType.title, for: .normal) - filterButton.setTitle(viewModel.filterType.title, for: .normal) - let displayIconName = viewModel.displayMode == .grid ? "icon_cloud_storage_show_type_list" : "icon_cloud_storage_show_type_table" - let displayFallback = viewModel.displayMode == .grid ? "list.bullet" : "square.grid.2x2" - displayModeButton.setImage(CloudDriveAsset.image(named: displayIconName, fallbackSystemName: displayFallback), for: .normal) - sortButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_list_sort", fallbackSystemName: "arrow.up.arrow.down"), for: .normal) - filterButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_filter", fallbackSystemName: "line.3.horizontal.decrease.circle"), for: .normal) - collectionView.setCollectionViewLayout(makeLayout(), animated: false) + sortButton.apply( + title: viewModel.sortType.title, + image: CloudDriveAsset.image(named: "icon_cloud_storage_list_sort", fallbackSystemName: "arrow.up.arrow.down") + ) + filterButton.apply( + title: viewModel.filterType.displayTitle, + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_filter", fallbackSystemName: "line.3.horizontal.decrease.circle") + ) + + let isGrid = viewModel.displayMode == .grid + let displayIcon = CloudDriveAsset.resizedImage( + named: isGrid ? "icon_cloud_storage_show_type_list" : "icon_cloud_storage_show_type_table", + fallbackSystemName: isGrid ? "list.bullet" : "square.grid.2x2", + size: CGSize(width: 16, height: 16) + ) + displayModeButton.setImage(displayIcon, for: .normal) + + let shouldReconfigureCells = appliedDisplayMode != nil && appliedDisplayMode != viewModel.displayMode + if appliedDisplayMode != viewModel.displayMode { + appliedDisplayMode = viewModel.displayMode + collectionView.setCollectionViewLayout(makeLayout(), animated: false) + } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.files) + if shouldReconfigureCells { + snapshot.reconfigureItems(viewModel.files) + } dataSource.apply(snapshot, animatingDifferences: true) - if viewModel.isLoading && viewModel.files.isEmpty { + if !viewModel.isRefreshing { + refreshControl.endRefreshing() + } + if viewModel.isLoading, !viewModel.files.isEmpty { + loadingMoreIndicator.startAnimating() + } else { + loadingMoreIndicator.stopAnimating() + } + if viewModel.isLoading, viewModel.files.isEmpty, !refreshControl.isRefreshing { showLoading() } else { hideLoading() } + updateInteractivePopGesture() } private func rebuildBreadcrumb() { - pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + pathStackView.arrangedSubviews.forEach { + pathStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + let homeButton = UIButton(type: .system) - homeButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_go_home", fallbackSystemName: "house.fill"), for: .normal) - homeButton.tintColor = AppColor.textPrimary + homeButton.setImage( + CloudDriveAsset.image(named: "icon_cloud_storage_go_home", fallbackSystemName: "house.fill")?.withRenderingMode(.alwaysOriginal), + for: .normal + ) + homeButton.imageView?.contentMode = .scaleAspectFit + homeButton.accessibilityLabel = "返回云盘首页" homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside) homeButton.snp.makeConstraints { make in - make.width.height.equalTo(24) + make.width.height.equalTo(16) } pathStackView.addArrangedSubview(homeButton) + pathStackView.addArrangedSubview(makeBreadcrumbSpacer(width: 8)) for (index, item) in viewModel.pathStack.enumerated() { if index > 0 { - let arrow = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_current_path_arrow", fallbackSystemName: "chevron.right")) - arrow.tintColor = AppColor.textTertiary - arrow.snp.makeConstraints { make in - make.width.equalTo(8) - } - pathStackView.addArrangedSubview(arrow) + let container = UIView() + let arrow = UIImageView( + image: CloudDriveAsset.image(named: "icon_cloud_storage_current_path_arrow", fallbackSystemName: "chevron.right") + ) + arrow.contentMode = .scaleAspectFit + container.addSubview(arrow) + container.snp.makeConstraints { make in make.width.equalTo(32) } + arrow.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.height.equalTo(16) } + pathStackView.addArrangedSubview(container) } + + let isLast = index == viewModel.pathStack.count - 1 let button = UIButton(type: .system) button.setTitle(item.name, for: .normal) - button.titleLabel?.font = .app(index == viewModel.pathStack.count - 1 ? .body : .caption) - button.setTitleColor(index == viewModel.pathStack.count - 1 ? .black : AppColor.textSecondary, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: isLast ? .semibold : .regular) + button.setTitleColor(isLast ? .black : UIColor(hex: 0x4B5563), for: .normal) + button.titleLabel?.lineBreakMode = .byTruncatingTail button.tag = index - button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside) + button.isUserInteractionEnabled = !isLast + if !isLast { + button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside) + } pathStackView.addArrangedSubview(button) } + + pathScrollView.layoutIfNeeded() + let rightOffset = max(0, pathScrollView.contentSize.width - pathScrollView.bounds.width) + pathScrollView.setContentOffset(CGPoint(x: rightOffset, y: 0), animated: false) } - private func configureMenus() { - sortButton.menu = UIMenu(children: CloudDriveSortType.allCases.map { type in - UIAction(title: type.title) { [weak self] _ in - guard let self else { return } - Task { await self.viewModel.chooseSortType(type, api: self.api) } - } - }) - sortButton.showsMenuAsPrimaryAction = true - - filterButton.menu = UIMenu(children: CloudDriveFilterType.allCases.map { type in - UIAction(title: type.title) { [weak self] _ in - guard let self else { return } - Task { await self.viewModel.chooseFilterType(type, api: self.api) } - } - }) - filterButton.showsMenuAsPrimaryAction = true - } - - private func configureFilterButton(_ button: UIButton) { - button.backgroundColor = AppColor.inputBackground - button.layer.cornerRadius = AppRadius.xs - button.titleLabel?.font = .app(.caption) - button.setTitleColor(AppColor.textPrimary, for: .normal) - button.contentHorizontalAlignment = .left - button.imageView?.contentMode = .scaleAspectFit - button.tintColor = AppColor.textPrimary - button.semanticContentAttribute = .forceLeftToRight - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.xs) - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.sm, bottom: 0, right: AppSpacing.sm) + private func makeBreadcrumbSpacer(width: CGFloat) -> UIView { + let spacer = UIView() + spacer.snp.makeConstraints { make in make.width.equalTo(width) } + return spacer } private func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { [weak self] _, environment in - let mode = self?.viewModel.displayMode ?? .grid - switch mode { + switch self?.viewModel.displayMode ?? .grid { case .grid: let columns = 2 let spacing: CGFloat = 15 let horizontalInset: CGFloat = 12 let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 let itemWidth = (availableWidth - spacing) / CGFloat(columns) - let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemWidth * 0.82)) + let itemHeight = itemWidth / 1.77 + 52 + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemHeight) + ) let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemWidth * 0.82)) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(itemHeight) + ) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: columns) group.interItemSpacing = .fixed(spacing) let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: horizontalInset, bottom: 12, trailing: horizontalInset) + section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) section.interGroupSpacing = spacing return section case .list: - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(72)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(72)) + let item = NSCollectionLayoutItem(layoutSize: size) + let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item]) let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: AppSpacing.md, bottom: AppSpacing.md, trailing: AppSpacing.md) + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) return section } } @@ -306,36 +450,49 @@ final class CloudDriveListViewController: BaseViewController { private func showControlSheet(for file: CloudFile) { viewModel.setControlFile(file) - let sheet = CloudDriveActionSheetViewController(title: "请选择", items: [ - CloudDriveActionSheetViewController.Item( - title: "删除", - image: CloudDriveAsset.image(named: "icon_cloud_storage_file_delete", fallbackSystemName: "trash"), - isDestructive: true - ) { [weak self] in self?.confirmDelete() }, - CloudDriveActionSheetViewController.Item( - title: "下载", - image: CloudDriveAsset.image(named: "icon_cloud_storage_file_download", fallbackSystemName: "arrow.down.circle"), - isDestructive: false - ) { [weak self] in self?.download(file) }, - CloudDriveActionSheetViewController.Item( - title: "移动", - image: CloudDriveAsset.image(named: "icon_cloud_storage_file_move", fallbackSystemName: "folder"), - isDestructive: false - ) { [weak self] in self?.showMoveDialog() }, - CloudDriveActionSheetViewController.Item( - title: "详情", - image: CloudDriveAsset.image(named: "icon_cloud_storage_file_item_details", fallbackSystemName: "info.circle"), - isDestructive: false - ) { [weak self] in self?.showDetailDialog(file) } - ]) - present(sheet, animated: true) + let sheet = CloudDriveActionSheetViewController( + title: "请选择", + layout: .controls, + items: [ + .init( + title: "删除", + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_delete", fallbackSystemName: "trash"), + isDestructive: false, + handler: { [weak self] in self?.confirmDelete() } + ), + .init( + title: "下载", + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_download", fallbackSystemName: "arrow.down.circle"), + isDestructive: false, + handler: { [weak self] in self?.download(file) } + ), + .init( + title: "移动", + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_move", fallbackSystemName: "folder"), + isDestructive: false, + handler: { [weak self] in self?.showMoveDialog() } + ), + .init( + title: "详情", + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "info.circle"), + isDestructive: false, + handler: { [weak self] in self?.showDetailDialog(file) } + ), + ] + ) + present(sheet, animated: false) } private func confirmDelete() { - showAlert(title: "删除确认", message: "删除后无法恢复,确认删除吗?") { [weak self] in - guard let self else { return } - Task { await self.viewModel.deleteCurrentFile(api: self.api) } - } + let dialog = CloudDriveConfirmationDialogViewController( + title: "删除确认", + message: "删除后无法恢复,确认删除吗?", + onConfirm: { [weak self] in + guard let self else { return } + Task { await self.viewModel.deleteCurrentFile(api: self.api) } + } + ) + present(dialog, animated: false) } private func download(_ file: CloudFile) { @@ -347,21 +504,20 @@ final class CloudDriveListViewController: BaseViewController { showToast("文件地址不存在") return } - transferManager.addDownloadTask(fileURL: file.fileUrl, fileName: file.name, fileType: file.type, fileSize: file.fileSize) + transferManager.addDownloadTask( + fileURL: file.fileUrl, + fileName: file.name, + fileType: file.type, + fileSize: file.fileSize + ) } private func showMoveDialog() { - let items = viewModel.moveTargetFolders().map { folder in - CloudDriveActionSheetViewController.Item( - title: folder.name, - image: CloudDriveAsset.image(named: "icon_cloud_storage_file_type_folder", fallbackSystemName: "folder"), - isDestructive: false - ) { [weak self] in - guard let self else { return } - Task { await self.viewModel.moveCurrentFile(targetFolderId: folder.id, api: self.api) } - } + let dialog = CloudDriveMoveDialogViewController(folders: viewModel.moveTargetFolders()) { [weak self] folderID in + guard let self else { return } + Task { await self.viewModel.moveCurrentFile(targetFolderId: folderID, api: self.api) } } - present(CloudDriveActionSheetViewController(title: "移动到", items: items), animated: true) + present(dialog, animated: false) } private func showDetailDialog(_ file: CloudFile) { @@ -369,41 +525,20 @@ final class CloudDriveListViewController: BaseViewController { guard let self else { return } Task { await self.viewModel.saveCurrentFileName(name, api: self.api) } } - present(sheet, animated: true) + present(sheet, animated: false) } - @objc private func uploadTapped() { - let sheet = CloudDriveActionSheetViewController(title: "选择上传类型", items: [ - CloudDriveActionSheetViewController.Item( - title: "上传图片", - image: CloudDriveAsset.image(named: "icon_cloud_storage_add_photo", fallbackSystemName: "photo"), - isDestructive: false - ) { [weak self] in self?.beginPick(type: 1) }, - CloudDriveActionSheetViewController.Item( - title: "上传视频", - image: CloudDriveAsset.image(named: "icon_cloud_storage_add_video", fallbackSystemName: "video"), - isDestructive: false - ) { [weak self] in self?.beginPick(type: 2) }, - CloudDriveActionSheetViewController.Item( - title: "新建文件夹", - image: CloudDriveAsset.image(named: "icon_cloud_storage_add_folder", fallbackSystemName: "folder.badge.plus"), - isDestructive: false - ) { [weak self] in self?.showCreateFolderDialog() } - ]) - present(sheet, animated: true) - } - - private func beginPick(type: Int) { + private func beginPick(fileType: Int) { Task { guard await viewModel.checkCanUpload(api: api) else { return } await MainActor.run { var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.selectionLimit = 1 - configuration.filter = type == 1 ? .images : .videos + configuration.filter = fileType == 2 ? .images : .videos let picker = PHPickerViewController(configuration: configuration) picker.delegate = self - picker.view.tag = type - present(picker, animated: true) + picker.view.tag = fileType + self.present(picker, animated: true) } } } @@ -413,32 +548,122 @@ final class CloudDriveListViewController: BaseViewController { showToast("不能创建更深的文件夹") return } - let alert = UIAlertController(title: "新建文件夹", message: nil, preferredStyle: .alert) - alert.addTextField { textField in - textField.placeholder = "请输入文件夹名称" - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self, weak alert] _ in + let dialog = CloudDriveTextInputDialogViewController( + title: "新建文件夹", + placeholder: "请输入文件夹名称" + ) { [weak self] name in guard let self else { return } - let name = alert?.textFields?.first?.text ?? "" Task { await self.viewModel.addFolder(name: name, api: self.api) } - }) - present(alert, animated: true) + } + present(dialog, animated: false) + } + + private func showDropdown( + from sourceView: UIView, + items: [CloudDriveDropdownViewController.Item] + ) { + present(CloudDriveDropdownViewController(sourceView: sourceView, items: items), animated: false) + } + + private func updateInteractivePopGesture() { + navigationController?.interactivePopGestureRecognizer?.isEnabled = viewModel.pathStack.count == 1 + } + + private func shouldHandleInteraction(_ key: String, interval: TimeInterval = 0.5) -> Bool { + let now = ProcessInfo.processInfo.systemUptime + if let previous = lastInteractionTimes[key], now - previous < interval { + return false + } + lastInteractionTimes[key] = now + return true + } + + @objc private func backTapped() { + guard shouldHandleInteraction("back", interval: 1) else { return } + Task { + let handled = await viewModel.navigateBack(api: api) + if !handled { + _ = await MainActor.run { self.navigationController?.popViewController(animated: true) } + } + } + } + + @objc private func refreshTriggered() { + Task { await viewModel.refresh(api: api, showLoading: false) } + } + + @objc private func sortTapped() { + guard shouldHandleInteraction("sort") else { return } + showDropdown( + from: sortButton, + items: CloudDriveSortType.allCases.map { type in + .init(title: type.title, isSelected: type == viewModel.sortType) { [weak self] in + guard let self else { return } + Task { await self.viewModel.chooseSortType(type, api: self.api) } + } + } + ) + } + + @objc private func filterTapped() { + guard shouldHandleInteraction("filter") else { return } + showDropdown( + from: filterButton, + items: CloudDriveFilterType.androidMenuOrder.map { type in + .init(title: type.menuTitle, isSelected: type == viewModel.filterType) { [weak self] in + guard let self else { return } + Task { await self.viewModel.chooseFilterType(type, api: self.api) } + } + } + ) + } + + @objc private func uploadTapped() { + guard shouldHandleInteraction("upload") else { return } + let sheet = CloudDriveActionSheetViewController( + title: "选择上传类型", + layout: .upload, + items: [ + .init( + title: "上传图片", + image: CloudDriveAsset.image(named: "icon_cloud_storage_add_photo", fallbackSystemName: "photo"), + isDestructive: false, + handler: { [weak self] in self?.beginPick(fileType: 2) } + ), + .init( + title: "上传视频", + image: CloudDriveAsset.image(named: "icon_cloud_storage_add_video", fallbackSystemName: "video"), + isDestructive: false, + handler: { [weak self] in self?.beginPick(fileType: 1) } + ), + .init( + title: "新建文件夹", + image: CloudDriveAsset.image(named: "icon_cloud_storage_add_folder", fallbackSystemName: "folder.badge.plus"), + isDestructive: false, + handler: { [weak self] in self?.showCreateFolderDialog() } + ), + ] + ) + present(sheet, animated: false) } @objc private func displayModeTapped() { + guard shouldHandleInteraction("display") else { return } viewModel.toggleDisplayMode() } @objc private func openTransferManager() { + guard shouldHandleInteraction("transfer", interval: 1) else { return } navigationController?.pushViewController(CloudDriveTransferViewController(manager: transferManager), animated: true) } @objc private func homeTapped() { + guard shouldHandleInteraction("home") else { return } Task { await viewModel.goHome(api: api) } } @objc private func pathTapped(_ sender: UIButton) { + guard shouldHandleInteraction("path_\(sender.tag)") else { return } Task { await viewModel.navigateToPathIndex(sender.tag, api: api) } } @@ -457,8 +682,13 @@ final class CloudDriveListViewController: BaseViewController { } guard let url else { return } do { - let stagedURL = try Self.copyPickedFileToStage(url: url, suggestedName: provider.suggestedName, fileType: fileType) - let size = (try? FileManager.default.attributesOfItem(atPath: stagedURL.path)[.size] as? NSNumber)?.int64Value ?? 0 + let stagedURL = try Self.copyPickedFileToStage( + url: url, + suggestedName: provider.suggestedName, + fileType: fileType + ) + let attributes = try? FileManager.default.attributesOfItem(atPath: stagedURL.path) + let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 Task { @MainActor in self.transferManager.addUploadTask( localFileURL: stagedURL, @@ -481,9 +711,6 @@ final class CloudDriveListViewController: BaseViewController { let baseName = CloudTransferFileName.sanitized(suggestedName ?? "cloud_upload") let fileName = baseName.lowercased().hasSuffix(".\(ext.lowercased())") ? baseName : "\(baseName).\(ext)" let destination = directory.appendingPathComponent("\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))_\(fileName)") - if FileManager.default.fileExists(atPath: destination.path) { - try FileManager.default.removeItem(at: destination) - } try FileManager.default.copyItem(at: url, to: destination) return destination } @@ -500,7 +727,8 @@ extension CloudDriveListViewController: UITextFieldDelegate { extension CloudDriveListViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let file = dataSource.itemIdentifier(for: indexPath) else { return } + guard let file = dataSource.itemIdentifier(for: indexPath), + shouldHandleInteraction("file_\(file.id)") else { return } Task { if let preview = await viewModel.handleFileTap(file, api: api) { await MainActor.run { self.openPreview(file: preview) } @@ -509,21 +737,151 @@ extension CloudDriveListViewController: UICollectionViewDelegate { } func scrollViewDidScroll(_ scrollView: UIScrollView) { - let offsetY = scrollView.contentOffset.y - let contentHeight = scrollView.contentSize.height - let frameHeight = scrollView.frame.size.height - if offsetY > contentHeight - frameHeight - 120 { - let lastIndex = max(0, viewModel.files.count - 1) - Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: lastIndex, api: api) } - } + guard scrollView.contentSize.height > scrollView.bounds.height else { return } + let threshold = scrollView.contentSize.height - scrollView.bounds.height - 120 + guard scrollView.contentOffset.y >= threshold else { return } + let lastIndex = max(0, viewModel.files.count - 1) + Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: lastIndex, api: api) } } } extension CloudDriveListViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - let type = picker.view.tag + let fileType = picker.view.tag picker.dismiss(animated: true) guard let result = results.first else { return } - stagePickedFile(result: result, fileType: type == 2 ? 1 : 2) + stagePickedFile(result: result, fileType: fileType) + } +} + +/// Android 云盘筛选条按钮,保证文案与图标分居两端。 +private final class CloudDriveMenuButton: UIButton { + private let valueLabel = UILabel() + private let iconView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hex: 0xF4F4F4) + layer.cornerRadius = 4 + valueLabel.font = .systemFont(ofSize: 14) + valueLabel.textColor = UIColor(hex: 0x333333) + valueLabel.isUserInteractionEnabled = false + iconView.contentMode = .scaleAspectFit + iconView.isUserInteractionEnabled = false + addSubview(valueLabel) + addSubview(iconView) + valueLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8) + } + iconView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() + make.width.height.equalTo(16) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新按钮文案与图标。 + func apply(title: String, image: UIImage?) { + valueLabel.text = title + iconView.image = image?.withRenderingMode(.alwaysOriginal) + accessibilityLabel = title + } +} + +/// Android 风格的轻量下拉菜单。 +private final class CloudDriveDropdownViewController: UIViewController { + /// 下拉菜单项。 + struct Item { + let title: String + let isSelected: Bool + let handler: () -> Void + } + + private weak var sourceView: UIView? + private let items: [Item] + private let dismissButton = UIButton(type: .custom) + private let menuView = UIView() + private let stackView = UIStackView() + + /// 创建锚定到指定按钮的下拉菜单。 + init(sourceView: UIView, items: [Item]) { + self.sourceView = sourceView + self.items = items + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) + menuView.backgroundColor = .white + menuView.layer.cornerRadius = 8 + menuView.layer.shadowColor = UIColor.black.cgColor + menuView.layer.shadowOpacity = 0.16 + menuView.layer.shadowRadius = 10 + menuView.layer.shadowOffset = CGSize(width: 0, height: 4) + stackView.axis = .vertical + + view.addSubview(dismissButton) + view.addSubview(menuView) + menuView.addSubview(stackView) + dismissButton.snp.makeConstraints { make in make.edges.equalToSuperview() } + stackView.snp.makeConstraints { make in make.edges.equalToSuperview() } + + items.enumerated().forEach { index, item in + let button = UIButton(type: .system) + var configuration = UIButton.Configuration.plain() + configuration.title = item.title + configuration.baseForegroundColor = item.isSelected ? AppColor.primary : UIColor(hex: 0x333333) + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + configuration.titleAlignment = .leading + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = .systemFont(ofSize: 14) + return attributes + } + button.configuration = configuration + button.tag = index + button.addTarget(self, action: #selector(itemTapped(_:)), for: .touchUpInside) + button.snp.makeConstraints { make in make.height.equalTo(48) } + stackView.addArrangedSubview(button) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let sourceView else { return } + let sourceFrame = sourceView.convert(sourceView.bounds, to: view) + let width: CGFloat = 165 + let height = CGFloat(items.count) * 48 + let x = min(max(8, sourceFrame.minX), view.bounds.width - width - 8) + var y = sourceFrame.maxY + if y + height > view.bounds.height - 8 { + y = sourceFrame.minY - height + } + menuView.frame = CGRect(x: x, y: y, width: width, height: height) + } + + @objc private func dismissTapped() { + dismiss(animated: false) + } + + @objc private func itemTapped(_ sender: UIButton) { + guard items.indices.contains(sender.tag) else { return } + let handler = items[sender.tag].handler + dismiss(animated: false, completion: handler) } } diff --git a/suixinkan/UI/CloudDrive/CloudDrivePreviewViewController.swift b/suixinkan/UI/CloudDrive/CloudDrivePreviewViewController.swift index 552a871..311d9c9 100644 --- a/suixinkan/UI/CloudDrive/CloudDrivePreviewViewController.swift +++ b/suixinkan/UI/CloudDrive/CloudDrivePreviewViewController.swift @@ -8,13 +8,17 @@ import Kingfisher import SnapKit import UIKit -/// 云盘文件预览页,黑底展示图片或视频。 +/// 云盘文件预览页,严格对齐 Android 黑底预览并保留 iOS 原生视频控制层。 final class CloudDrivePreviewViewController: BaseViewController { private let file: CloudFile private let imageView = UIImageView() private var playerViewController: AVPlayerViewController? + private var previousStandardAppearance: UINavigationBarAppearance? + private var previousScrollEdgeAppearance: UINavigationBarAppearance? + private var previousCompactAppearance: UINavigationBarAppearance? + private var previousTintColor: UIColor? - /// 初始化预览页。 + /// 初始化云盘文件预览页。 init(file: CloudFile) { self.file = file super.init(nibName: nil, bundle: nil) @@ -25,9 +29,23 @@ final class CloudDrivePreviewViewController: BaseViewController { fatalError("init(coder:) has not been implemented") } + override var preferredStatusBarStyle: UIStatusBarStyle { + .lightContent + } + override func setupNavigationBar() { - title = "" - navigationController?.navigationBar.tintColor = .white + navigationItem.titleView = UIView() + 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() { @@ -39,37 +57,71 @@ final class CloudDrivePreviewViewController: BaseViewController { } } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + applyTransparentNavigationBar() + setNeedsStatusBarAppearanceUpdate() + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - navigationController?.navigationBar.tintColor = AppColor.primary + restoreNavigationBar() playerViewController?.player?.pause() } private func setupImage() { imageView.contentMode = .scaleAspectFit imageView.backgroundColor = .black + imageView.accessibilityLabel = file.name view.addSubview(imageView) - imageView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) - } - if let url = URL(string: file.fileUrl) { + imageView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) } + if let url = URL(string: file.fileUrl), !file.fileUrl.isEmpty { imageView.kf.setImage(with: url) } } 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 controller = AVPlayerViewController() controller.player = player controller.view.backgroundColor = .black addChild(controller) view.addSubview(controller.view) - controller.view.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) - } + controller.view.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) } controller.didMove(toParent: self) playerViewController = controller 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) + } } diff --git a/suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift b/suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift index b105817..96e1475 100644 --- a/suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift +++ b/suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift @@ -6,21 +6,34 @@ import SnapKit import UIKit -/// 云盘传输管理页,对齐 Android `CloudStorageTransitScreen`。 +/// 云盘传输管理页,严格对齐 Android `CloudStorageTransitScreen`。 final class CloudDriveTransferViewController: BaseViewController { private enum Tab: Int { case upload case download } + private enum Section { + case main + } + 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 var dataSource: UITableViewDiffableDataSource! 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) { - self.manager = manager + init(manager: CloudTransferManager? = nil) { + self.manager = manager ?? .shared super.init(nibName: nil, bundle: nil) } @@ -30,81 +43,217 @@ final class CloudDriveTransferViewController: BaseViewController { } 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() { 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.separatorStyle = .none - tableView.dataSource = self + tableView.showsVerticalScrollIndicator = false + tableView.contentInsetAdjustmentBehavior = .never tableView.delegate = self 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) + + configureDataSource() } override func setupConstraints() { - segmentedControl.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.height.equalTo(36) + navigationDivider.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + 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 - make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.sm) + make.top.equalTo(tabContainer.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } } override func bindActions() { - segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged) manager.onTasksChange = { [weak self] in - Task { @MainActor in self?.tableView.reloadData() } + 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] { selectedTab == .upload ? manager.uploadTasks : manager.downloadTasks } - @objc private func tabChanged() { - selectedTab = Tab(rawValue: segmentedControl.selectedSegmentIndex) ?? .upload - tableView.reloadData() - } -} - -extension CloudDriveTransferViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - visibleTasks.count + private func configureTabButton(_ button: UIButton, title: String, action: Selector) { + button.setTitle(title, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14) + button.addTarget(self, action: action, for: .touchUpInside) } - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 112 + private func applyCloudNavigationBarAppearance() { + guard let navigationBar = navigationController?.navigationBar else { return } + previousStandardAppearance = navigationBar.standardAppearance + previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance + previousCompactAppearance = navigationBar.compactAppearance + previousNavigationTintColor = navigationBar.tintColor + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .white + appearance.shadowColor = .clear + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.tintColor = .black } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: CloudTransferCell.reuseIdentifier, for: indexPath) as! CloudTransferCell - let task = visibleTasks[indexPath.row] - cell.apply(task: task) - cell.onPrimaryAction = { [weak self] in - guard let self else { return } - if task.kind == .upload { - task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id) - } else { - task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id) + private func restoreNavigationBarAppearance() { + guard let navigationBar = navigationController?.navigationBar else { return } + if let previousStandardAppearance { + navigationBar.standardAppearance = previousStandardAppearance + } + navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance + navigationBar.compactAppearance = previousCompactAppearance + navigationBar.tintColor = previousNavigationTintColor + } + + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource(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.onPrimaryAction = { [weak self] in + guard let self else { return } + switch task.kind { + case .upload: + task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id) + case .download: + task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id) + } + } + cell.onCancel = { [weak self] in + guard let self else { return } + task.kind == .upload ? self.manager.cancelUpload(id: task.id) : self.manager.cancelDownload(id: task.id) + } + return cell } - cell.onCancel = { [weak self] in - guard let self else { return } - task.kind == .upload ? self.manager.cancelUpload(id: task.id) : self.manager.cancelDownload(id: task.id) + dataSource.defaultRowAnimation = .fade + } + + private func applySnapshot(animated: Bool) { + let previousIDs = Set(dataSource.snapshot().itemIdentifiers) + let taskIDs = visibleTasks.map(\.id) + var snapshot = NSDiffableDataSourceSnapshot() + 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) } - return cell + 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 { static let reuseIdentifier = "CloudTransferCell" @@ -137,90 +286,124 @@ private final class CloudTransferCell: UITableViewCell { onCancel = nil } + /// 应用传输任务状态。 func apply(task: CloudTransferTask) { iconView.image = CloudDriveAsset.image( named: task.fileType == 1 ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo", fallbackSystemName: task.fileType == 1 ? "video.fill" : "photo.fill" ) titleLabel.text = task.fileName - statusLabel.text = "\(task.status.title) \(CloudDriveFormatter.fileSize(task.fileSize))" + statusLabel.text = statusText(for: task) progressView.progress = Float(task.progress) / 100 percentLabel.text = "\(task.progress)%" + let isRunning = task.status == .uploading || task.status == .downloading - primaryButton.setImage(UIImage(systemName: isRunning ? "pause.fill" : "play.fill"), for: .normal) - primaryButton.isHidden = task.status == .completed + let canResume = task.status == .paused || task.status == .failed + 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() { backgroundColor = .clear selectionStyle = .none cardView.backgroundColor = .white - cardView.layer.cornerRadius = AppRadius.sm + cardView.layer.cornerRadius = 8 cardView.clipsToBounds = true - iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit - titleLabel.font = .app(.body) - titleLabel.textColor = AppColor.textPrimary + titleLabel.font = .systemFont(ofSize: 15) + titleLabel.textColor = UIColor(hex: 0x333333) titleLabel.lineBreakMode = .byTruncatingMiddle - statusLabel.font = .app(.caption) - statusLabel.textColor = AppColor.textTertiary + statusLabel.font = .systemFont(ofSize: 12) + statusLabel.textColor = .gray progressView.progressTintColor = AppColor.primary - percentLabel.font = .app(.caption) - percentLabel.textColor = AppColor.textSecondary + progressView.trackTintColor = UIColor(hex: 0xE5E7EB) + progressView.transform = CGAffineTransform(scaleX: 1, y: 2) + percentLabel.font = .systemFont(ofSize: 11) + percentLabel.textColor = UIColor(hex: 0x4B5563) percentLabel.textAlignment = .right - primaryButton.tintColor = AppColor.primary - cancelButton.tintColor = AppColor.textSecondary - cancelButton.setImage(UIImage(systemName: "xmark"), for: .normal) + primaryButton.tintColor = UIColor(hex: 0x333333) + cancelButton.tintColor = UIColor(hex: 0x333333) + cancelButton.setImage( + UIImage(systemName: "xmark")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20)), + for: .normal + ) + primaryButton.accessibilityLabel = "暂停或继续" + cancelButton.accessibilityLabel = "取消任务" primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) contentView.addSubview(cardView) - cardView.addSubview(iconView) - cardView.addSubview(titleLabel) - cardView.addSubview(statusLabel) - cardView.addSubview(primaryButton) - cardView.addSubview(cancelButton) - cardView.addSubview(progressView) - cardView.addSubview(percentLabel) + [iconView, titleLabel, statusLabel, primaryButton, cancelButton, progressView, percentLabel] + .forEach(cardView.addSubview) } private func setupConstraints() { cardView.snp.makeConstraints { make in - make.top.bottom.equalToSuperview().inset(AppSpacing.xs) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.top.equalToSuperview().offset(12) + make.leading.trailing.equalToSuperview().inset(12) + make.bottom.equalToSuperview() } iconView.snp.makeConstraints { make in - make.leading.top.equalToSuperview().offset(AppSpacing.sm) + make.leading.top.equalToSuperview().offset(12) make.width.height.equalTo(48) } cancelButton.snp.makeConstraints { make in - make.trailing.top.equalToSuperview().inset(AppSpacing.xs) - make.width.height.equalTo(36) + make.trailing.equalToSuperview() + make.top.equalToSuperview().offset(8) + make.width.height.equalTo(44) } primaryButton.snp.makeConstraints { make in make.trailing.equalTo(cancelButton.snp.leading) make.centerY.equalTo(cancelButton) - make.width.height.equalTo(36) + make.width.height.equalTo(44) } 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.trailing.equalTo(primaryButton.snp.leading).offset(-AppSpacing.xs) + make.trailing.equalTo(primaryButton.snp.leading).offset(-4) } statusLabel.snp.makeConstraints { make in 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 - make.leading.equalTo(iconView) - make.trailing.equalToSuperview().inset(AppSpacing.sm) - make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm) + make.leading.equalToSuperview().offset(12) + make.trailing.equalToSuperview().inset(12) + make.top.equalTo(iconView.snp.bottom).offset(12) + make.height.equalTo(4) } 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.bottom.equalToSuperview().inset(12) } } diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift index 38289c0..c4e9c25 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -10,6 +10,8 @@ import UIKit /// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。 final class WildReportRiskMapViewController: BaseViewController { + private static let cachedNavigationAppKey = "wildReportRiskMap.cachedNavigationApp" + private let viewModel: WildReportRiskMapViewModel private let api: any WildPhotographerReportServing private let locationProvider: any LocationProviding @@ -907,6 +909,10 @@ final class WildReportRiskMapViewController: BaseViewController { return } guard let target = viewModel.navigateToSelectedMarker() else { return } + if let cachedOption = cachedNavigationOption(for: target) { + openNavigation(cachedOption, cacheSelection: false) + return + } presentNavigationOptions(for: target) } @@ -930,7 +936,7 @@ final class WildReportRiskMapViewController: BaseViewController { let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet) options.forEach { option 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)) @@ -942,43 +948,71 @@ final class WildReportRiskMapViewController: BaseViewController { } private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] { - [ - WildReportNavigationOption(title: "Apple 地图", url: navigationURL(for: .apple, target: target)), - WildReportNavigationOption(title: "高德地图", url: navigationURL(for: .amap, target: target)), - 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 } + WildReportNavigationApp.allCases.compactMap { app in + guard let option = navigationOption(for: app, target: target) else { return nil } + guard isNavigationOptionAvailable(option) else { return nil } 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 } UIApplication.shared.open(url) { [weak self] success in - guard !success else { return } + guard success else { + UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey) + Task { @MainActor in + self?.showToast("无法打开\(option.title)") + } + return + } + guard cacheSelection else { return } Task { @MainActor in - self?.showToast("未安装\(option.title)或无法打开") + UserDefaults.standard.set(option.app.rawValue, forKey: Self.cachedNavigationAppKey) } } } private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? { - let latitude = target.coordinate.latitude - let longitude = target.coordinate.longitude + let destLat = target.coordinate.latitude + let destLon = target.coordinate.longitude let name = encodedNavigationText(target.title) switch app { 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: - 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: - 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: - 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: - 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 { + let app: WildReportNavigationApp let title: String let url: URL? + let requiresAvailabilityCheck: Bool } -private enum WildReportNavigationApp { +/// 风险地图支持打开的导航软件。 +private enum WildReportNavigationApp: String, CaseIterable { case apple case amap case baidu case tencent 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 { diff --git a/suixinkanTests/CloudDriveViewModelTests.swift b/suixinkanTests/CloudDriveViewModelTests.swift index d278346..9cbd8b9 100644 --- a/suixinkanTests/CloudDriveViewModelTests.swift +++ b/suixinkanTests/CloudDriveViewModelTests.swift @@ -9,12 +9,47 @@ import XCTest @MainActor /// 相册云盘 ViewModel 测试。 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 { let api = MockCloudDriveAPI() api.listResponses = [ CloudFileListResponse(total: 3, list: [file(id: 1), folder(id: 2)]), CloudFileListResponse(total: 3, list: [file(id: 3)]), CloudFileListResponse(total: 1, list: [file(id: 4)]), + CloudFileListResponse(total: 1, list: [file(id: 5)]), CloudFileListResponse(total: 0, list: []), ] let viewModel = CloudDriveListViewModel() @@ -23,13 +58,16 @@ final class CloudDriveViewModelTests: XCTestCase { await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api) viewModel.updateSearchText("图") await viewModel.chooseFilterType(.image, api: api) + await viewModel.chooseSortType(.createdAscending, api: api) _ = await viewModel.handleFileTap(folder(id: 9), api: api) XCTAssertEqual(viewModel.files.map(\.id), []) 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].type, 2) + XCTAssertEqual(api.listCalls[3].orderBy, 1) + XCTAssertEqual(api.listCalls[4].name, "") } func testBackRestoresCachedRootAndGoHome() async { @@ -50,6 +88,58 @@ final class CloudDriveViewModelTests: XCTestCase { 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 { let api = MockCloudDriveAPI() api.listResponses = Array(repeating: CloudFileListResponse(), count: 5) @@ -83,6 +173,38 @@ final class CloudDriveViewModelTests: XCTestCase { 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 { CloudFile(id: id, fileUrl: "https://cdn/\(id).jpg", name: "文件\(id)", type: type) } diff --git a/suixinkanTests/CloudTransferManagerTests.swift b/suixinkanTests/CloudTransferManagerTests.swift index 8dec4cd..8de6ba6 100644 --- a/suixinkanTests/CloudTransferManagerTests.swift +++ b/suixinkanTests/CloudTransferManagerTests.swift @@ -77,6 +77,104 @@ final class CloudTransferManagerTests: XCTestCase { 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 { let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + name) try Data([1, 2, 3]).write(to: url) @@ -96,6 +194,20 @@ final class CloudTransferManagerTests: XCTestCase { 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 { @@ -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 { 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 { var saved: [(fileURL: URL, fileType: Int)] = []