Update OTG transfer workflow
This commit is contained in:
@ -16,7 +16,7 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
||||
}
|
||||
|
||||
private let viewModel: TravelAlbumCameraImportViewModel
|
||||
var onImportFinished: ((Int, Int) -> Void)?
|
||||
var onImportFinished: ((Int, Int, [String]) -> Void)?
|
||||
var onImportCancelled: (() -> Void)?
|
||||
|
||||
private var collectionView: UICollectionView!
|
||||
@ -193,11 +193,11 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
||||
navigationItem.leftBarButtonItem?.isEnabled = false
|
||||
activityIndicator.startAnimating()
|
||||
|
||||
case .finished(let importedCount, let failedCount):
|
||||
case .finished(let importedCount, let failedCount, let importedPhotoIds):
|
||||
isModalInPresentation = false
|
||||
activityIndicator.stopAnimating()
|
||||
dismiss(animated: true) { [weak self] in
|
||||
self?.onImportFinished?(importedCount, failedCount)
|
||||
self?.onImportFinished?(importedCount, failedCount, importedPhotoIds)
|
||||
}
|
||||
|
||||
case .failed(let message):
|
||||
|
||||
@ -4,8 +4,10 @@
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
private enum WiredTransferMetrics {
|
||||
static let sidebarWidth: CGFloat = 76
|
||||
@ -43,7 +45,11 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
private let retouchButton = UIButton(type: .system)
|
||||
private let formatButton = UIButton(type: .system)
|
||||
private let modeButton = UIButton(type: .system)
|
||||
private let modeChevronView = UIImageView()
|
||||
private let settingsStatsDivider = UIView()
|
||||
private let statsCard = UIStackView()
|
||||
private let firstStatsDivider = UIView()
|
||||
private let secondStatsDivider = UIView()
|
||||
private let photoPanelView = UIView()
|
||||
private let selectAllBar = UIControl()
|
||||
private let selectAllIconView = UIImageView()
|
||||
@ -61,9 +67,12 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
private var pendingScrollSlotId: String?
|
||||
private var selectAllBarHeightConstraint: Constraint?
|
||||
private let bottomBar = UIView()
|
||||
private let bottomButtonsStack = UIStackView()
|
||||
private let batchButton = UIButton(type: .system)
|
||||
private let historyImportButton = UIButton(type: .system)
|
||||
private let albumImportButton = UIButton(type: .system)
|
||||
private let specifyButton = UIButton(type: .system)
|
||||
private var isHistoryImportButtonVisible = false
|
||||
|
||||
init(viewModel: WiredCameraTransferViewModel) {
|
||||
self.viewModel = viewModel
|
||||
@ -121,9 +130,13 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
storageTitleLabel.textColor = AppColor.textPrimary
|
||||
storageTitleLabel.textAlignment = .center
|
||||
storageTitleLabel.adjustsFontSizeToFitWidth = true
|
||||
storageTitleLabel.minimumScaleFactor = 0.72
|
||||
storageValueLabel.font = .systemFont(ofSize: 10)
|
||||
storageValueLabel.textColor = AppColor.primary
|
||||
storageValueLabel.textAlignment = .center
|
||||
storageValueLabel.adjustsFontSizeToFitWidth = true
|
||||
storageValueLabel.minimumScaleFactor = 0.72
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
statusLabel.layer.cornerRadius = 4
|
||||
@ -139,13 +152,25 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
|
||||
chipsStack.axis = .horizontal
|
||||
chipsStack.spacing = 6
|
||||
chipsStack.distribution = .fillEqually
|
||||
[retouchButton, formatButton, modeButton].forEach {
|
||||
configureChipButton($0)
|
||||
chipsStack.addArrangedSubview($0)
|
||||
}
|
||||
retouchButton.isUserInteractionEnabled = false
|
||||
formatButton.isUserInteractionEnabled = false
|
||||
modeButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 32)
|
||||
modeChevronView.image = UIImage(systemName: "chevron.down")?
|
||||
.withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold))
|
||||
modeChevronView.tintColor = AppColor.textTertiary
|
||||
modeChevronView.contentMode = .scaleAspectFit
|
||||
modeChevronView.isUserInteractionEnabled = false
|
||||
settingsStatsDivider.backgroundColor = AppColor.border
|
||||
|
||||
statsCard.axis = .horizontal
|
||||
statsCard.distribution = .fillEqually
|
||||
firstStatsDivider.backgroundColor = AppColor.border
|
||||
secondStatsDivider.backgroundColor = AppColor.border
|
||||
|
||||
photoPanelView.backgroundColor = .white
|
||||
selectAllBar.backgroundColor = .white
|
||||
@ -200,9 +225,15 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
emptyLabel.textAlignment = .center
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
bottomButtonsStack.axis = .horizontal
|
||||
bottomButtonsStack.spacing = 8
|
||||
bottomButtonsStack.distribution = .fillEqually
|
||||
configureBottomButton(batchButton, title: "批量上传", filled: true)
|
||||
configureBottomButton(historyImportButton, title: "历史导入", filled: false)
|
||||
configureBottomButton(albumImportButton, title: "相册导入", filled: false)
|
||||
configureBottomButton(specifyButton, title: "指定上传", filled: false)
|
||||
historyImportButton.isHidden = true
|
||||
historyImportButton.alpha = 0
|
||||
|
||||
view.addSubview(headerView)
|
||||
headerView.addSubview(backButton)
|
||||
@ -216,7 +247,10 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
statusCard.addSubview(refreshButton)
|
||||
statusCard.addSubview(helpLabel)
|
||||
statusCard.addSubview(chipsStack)
|
||||
statusCard.addSubview(settingsStatsDivider)
|
||||
statusCard.addSubview(statsCard)
|
||||
statsCard.addSubview(firstStatsDivider)
|
||||
statsCard.addSubview(secondStatsDivider)
|
||||
view.addSubview(photoPanelView)
|
||||
photoPanelView.addSubview(selectAllBar)
|
||||
selectAllBar.addSubview(selectAllIconView)
|
||||
@ -230,9 +264,12 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
listContainerView.addSubview(expandSidebarButton)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(batchButton)
|
||||
bottomBar.addSubview(historyImportButton)
|
||||
bottomBar.addSubview(specifyButton)
|
||||
bottomBar.addSubview(bottomButtonsStack)
|
||||
modeButton.addSubview(modeChevronView)
|
||||
bottomButtonsStack.addArrangedSubview(batchButton)
|
||||
bottomButtonsStack.addArrangedSubview(specifyButton)
|
||||
bottomButtonsStack.addArrangedSubview(albumImportButton)
|
||||
bottomButtonsStack.addArrangedSubview(historyImportButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
@ -259,8 +296,9 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
storagePanel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(14)
|
||||
make.width.greaterThanOrEqualTo(76)
|
||||
make.top.equalToSuperview().offset(14)
|
||||
make.leading.equalToSuperview().offset(14)
|
||||
make.width.equalTo(96)
|
||||
make.height.equalTo(76)
|
||||
}
|
||||
storageTitleLabel.snp.makeConstraints { make in
|
||||
@ -273,7 +311,8 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.equalTo(storagePanel.snp.trailing).offset(10)
|
||||
make.leading.equalTo(storagePanel.snp.trailing).offset(8)
|
||||
make.trailing.lessThanOrEqualTo(refreshButton.snp.leading).offset(-6)
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(84)
|
||||
}
|
||||
@ -281,41 +320,52 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
make.centerY.equalTo(statusLabel)
|
||||
make.trailing.equalToSuperview().offset(-14)
|
||||
make.height.equalTo(34)
|
||||
make.width.greaterThanOrEqualTo(72)
|
||||
make.width.greaterThanOrEqualTo(64)
|
||||
}
|
||||
helpLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusLabel.snp.bottom).offset(4)
|
||||
make.leading.equalTo(statusLabel)
|
||||
make.trailing.equalTo(refreshButton)
|
||||
make.trailing.equalToSuperview().offset(-14)
|
||||
}
|
||||
chipsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(storagePanel.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(14)
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
modeChevronView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-10)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
settingsStatsDivider.snp.makeConstraints { make in
|
||||
make.top.equalTo(chipsStack.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(1 / UIScreen.main.scale)
|
||||
}
|
||||
statsCard.snp.makeConstraints { make in
|
||||
make.top.equalTo(chipsStack.snp.bottom).offset(14)
|
||||
make.top.equalTo(settingsStatsDivider.snp.bottom).offset(2)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(68)
|
||||
}
|
||||
firstStatsDivider.snp.makeConstraints { make in
|
||||
make.width.equalTo(1 / UIScreen.main.scale)
|
||||
make.top.bottom.equalToSuperview().inset(14)
|
||||
make.centerX.equalToSuperview().multipliedBy(2.0 / 3.0)
|
||||
}
|
||||
secondStatsDivider.snp.makeConstraints { make in
|
||||
make.width.equalTo(1 / UIScreen.main.scale)
|
||||
make.top.bottom.equalToSuperview().inset(14)
|
||||
make.centerX.equalToSuperview().multipliedBy(4.0 / 3.0)
|
||||
}
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
batchButton.snp.makeConstraints { make in
|
||||
bottomButtonsStack.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(12)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(44)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
historyImportButton.snp.makeConstraints { make in
|
||||
make.top.height.width.equalTo(batchButton)
|
||||
make.leading.equalTo(batchButton.snp.trailing).offset(8)
|
||||
}
|
||||
specifyButton.snp.makeConstraints { make in
|
||||
make.top.height.width.equalTo(batchButton)
|
||||
make.leading.equalTo(historyImportButton.snp.trailing).offset(8)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
}
|
||||
photoPanelView.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusCard.snp.bottom).offset(14)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
@ -366,13 +416,11 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onLiveShotSaved = { [weak self] filename in
|
||||
Task { @MainActor in self?.showToast("已保存 \(filename)") }
|
||||
}
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
|
||||
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
|
||||
albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside)
|
||||
specifyButton.addTarget(self, action: #selector(specifyTapped), for: .touchUpInside)
|
||||
selectAllBar.addTarget(self, action: #selector(selectAllTapped), for: .touchUpInside)
|
||||
expandSidebarButton.addTarget(self, action: #selector(toggleSidebarTapped), for: .touchUpInside)
|
||||
@ -381,12 +429,8 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
self?.pendingScrollSlotId = slotId
|
||||
self?.viewModel.selectTimeSlot(id: slotId)
|
||||
}
|
||||
formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in
|
||||
UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) }
|
||||
})
|
||||
formatButton.showsMenuAsPrimaryAction = true
|
||||
modeButton.menu = UIMenu(children: ["边拍边传", "拍后传输"].map { option in
|
||||
UIAction(title: option) { [weak self] _ in self?.viewModel.selectTransferMode(option) }
|
||||
modeButton.menu = UIMenu(children: TravelAlbumOTGTransferMode.allCases.map { mode in
|
||||
UIAction(title: mode.title) { [weak self] _ in self?.viewModel.selectTransferMode(mode.title) }
|
||||
})
|
||||
modeButton.showsMenuAsPrimaryAction = true
|
||||
helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped)))
|
||||
@ -403,7 +447,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
|
||||
|
||||
retouchButton.setTitle(viewModel.retouchOption, for: .normal)
|
||||
formatButton.setTitle(viewModel.photoFormatOption.rawValue, for: .normal)
|
||||
formatButton.setTitle("JPG", for: .normal)
|
||||
modeButton.setTitle(viewModel.transferModeOption, for: .normal)
|
||||
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
|
||||
|
||||
@ -435,6 +479,37 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
batchButton.setTitle("批量上传", for: .normal)
|
||||
}
|
||||
historyImportButton.setTitle("历史导入", for: .normal)
|
||||
albumImportButton.setTitle("相册导入", for: .normal)
|
||||
specifyButton.isEnabled = viewModel.canOpenSpecifyUploadChooser
|
||||
specifyButton.alpha = specifyButton.isEnabled ? 1 : 0.55
|
||||
updateHistoryImportButtonVisibility(viewModel.isCameraConnected, animated: view.window != nil)
|
||||
}
|
||||
|
||||
private func updateHistoryImportButtonVisibility(_ visible: Bool, animated: Bool) {
|
||||
guard visible != isHistoryImportButtonVisible else { return }
|
||||
isHistoryImportButtonVisible = visible
|
||||
if visible {
|
||||
historyImportButton.isHidden = false
|
||||
historyImportButton.alpha = 0
|
||||
}
|
||||
let changes = {
|
||||
self.historyImportButton.alpha = visible ? 1 : 0
|
||||
if !visible {
|
||||
self.historyImportButton.isHidden = true
|
||||
}
|
||||
self.bottomBar.layoutIfNeeded()
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
guard animated else {
|
||||
changes()
|
||||
return
|
||||
}
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
delay: 0,
|
||||
options: [.beginFromCurrentState, .curveEaseInOut],
|
||||
animations: changes
|
||||
)
|
||||
}
|
||||
|
||||
private func updateSelectAllBar() {
|
||||
@ -488,7 +563,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
let selected = viewModel.selectedPhotoIds.contains(item.id)
|
||||
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
|
||||
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
|
||||
cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) }
|
||||
cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
|
||||
}
|
||||
|
||||
private func reconfigureVisiblePhotoCells() {
|
||||
@ -501,6 +576,31 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func syncSidebarSelectionWithCurrentScrollPosition() {
|
||||
guard !currentSections.isEmpty else { return }
|
||||
let visibleTopY = collectionView.contentOffset.y
|
||||
+ collectionView.adjustedContentInset.top
|
||||
+ WiredTransferMetrics.sectionHeaderHeight
|
||||
let visibleItems = collectionView.indexPathsForVisibleItems.compactMap { indexPath -> (IndexPath, CGRect)? in
|
||||
guard let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { return nil }
|
||||
return (indexPath, attributes.frame)
|
||||
}
|
||||
guard let target = visibleItems
|
||||
.filter({ $0.1.maxY >= visibleTopY })
|
||||
.min(by: { lhs, rhs in
|
||||
abs(lhs.1.minY - visibleTopY) < abs(rhs.1.minY - visibleTopY)
|
||||
}),
|
||||
let slotId = currentSections[safe: target.0.section]?.id,
|
||||
slotId != viewModel.selectedTimeSlotId else {
|
||||
return
|
||||
}
|
||||
viewModel.syncVisibleTimeSlot(id: slotId)
|
||||
sidebarView.apply(
|
||||
groups: viewModel.sidebarGroups,
|
||||
selectedSlotId: viewModel.selectedTimeSlotId
|
||||
)
|
||||
}
|
||||
|
||||
private func rebuildStats() {
|
||||
statsCard.arrangedSubviews.forEach { view in
|
||||
statsCard.removeArrangedSubview(view)
|
||||
@ -518,6 +618,8 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||||
statsCard.addArrangedSubview(button)
|
||||
}
|
||||
statsCard.bringSubviewToFront(firstStatsDivider)
|
||||
statsCard.bringSubviewToFront(secondStatsDivider)
|
||||
}
|
||||
|
||||
private func configureChipButton(_ button: UIButton) {
|
||||
@ -527,18 +629,34 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
button.layer.cornerRadius = 8
|
||||
button.layer.borderWidth = 1
|
||||
button.layer.borderColor = AppColor.border.cgColor
|
||||
button.contentHorizontalAlignment = .leading
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
|
||||
}
|
||||
|
||||
private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton {
|
||||
var config = UIButton.Configuration.plain()
|
||||
config.title = "\(count)\n\(title)"
|
||||
config.title = "\(count)"
|
||||
config.subtitle = title
|
||||
config.titleAlignment = .center
|
||||
config.baseForegroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
|
||||
config.titlePadding = 2
|
||||
config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
|
||||
let foregroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
|
||||
config.baseForegroundColor = foregroundColor
|
||||
config.background.backgroundColor = .white
|
||||
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .systemFont(ofSize: 17, weight: selected ? .semibold : .medium)
|
||||
outgoing.foregroundColor = foregroundColor
|
||||
return outgoing
|
||||
}
|
||||
config.subtitleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .systemFont(ofSize: 12, weight: selected ? .semibold : .regular)
|
||||
outgoing.foregroundColor = foregroundColor
|
||||
return outgoing
|
||||
}
|
||||
let button = UIButton(configuration: config)
|
||||
button.titleLabel?.numberOfLines = 2
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .semibold : .regular)
|
||||
button.contentVerticalAlignment = .center
|
||||
return button
|
||||
}
|
||||
|
||||
@ -596,15 +714,6 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
return text
|
||||
}
|
||||
|
||||
private func confirmDeletePhoto(_ photoId: String) {
|
||||
let alert = UIAlertController(title: "删除照片", message: "仅删除本地 OTG 缓存,不会删除服务端已上传素材。", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||
self?.viewModel.deletePhoto(photoId: photoId)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func backTapped() {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
@ -621,9 +730,9 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
|
||||
viewModel.pauseLiveTransferForHistoricalImport()
|
||||
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
|
||||
importController.onImportFinished = { [weak self] importedCount, failedCount in
|
||||
importController.onImportFinished = { [weak self] importedCount, failedCount, importedPhotoIds in
|
||||
guard let self else { return }
|
||||
self.viewModel.reloadLocalPhotos()
|
||||
self.viewModel.handleNewlyImportedPhotoIds(importedPhotoIds)
|
||||
self.viewModel.start()
|
||||
if failedCount > 0 {
|
||||
self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败")
|
||||
@ -639,8 +748,30 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
@objc private func albumImportTapped() {
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 0
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
@objc private func specifyTapped() {
|
||||
viewModel.onSpecifyUploadButtonClick()
|
||||
guard viewModel.canOpenSpecifyUploadChooser else { return }
|
||||
let controller = WiredSpecifyUploadViewController(options: viewModel.specifyUploadOptions)
|
||||
controller.onConfirm = { [weak self] option in
|
||||
self?.viewModel.onSpecifyUploadOptionSelected(option)
|
||||
}
|
||||
controller.modalPresentationStyle = .pageSheet
|
||||
if let sheet = controller.sheetPresentationController {
|
||||
sheet.detents = [
|
||||
.custom(identifier: .init("wiredSpecifyUpload")) { _ in 260 }
|
||||
]
|
||||
sheet.prefersGrabberVisible = false
|
||||
sheet.preferredCornerRadius = 16
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
@objc private func selectAllTapped() {
|
||||
@ -660,15 +791,293 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
|
||||
guard let url = item.thumbnailURL else {
|
||||
showToast("暂无可预览图片")
|
||||
return
|
||||
}
|
||||
let previewItem: MediaPreviewItem = url.isFileURL
|
||||
? .localImage(url: url)
|
||||
: MediaPreviewItem(source: .remoteImage(url))
|
||||
MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
|
||||
}
|
||||
}
|
||||
|
||||
extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
showLoading()
|
||||
let items = await loadPhoneAlbumImportItems(from: results)
|
||||
let result = viewModel.importPhoneAlbumImages(items)
|
||||
hideLoading()
|
||||
if result.failedCount > 0 {
|
||||
showToast("已导入 \(result.importedCount) 张,\(result.failedCount) 张失败")
|
||||
} else if result.importedCount > 0 {
|
||||
showToast("已导入 \(result.importedCount) 张照片")
|
||||
} else {
|
||||
showToast("未能导入照片")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPhoneAlbumImportItems(from results: [PHPickerResult]) async -> [TravelAlbumPhoneAlbumImportItem] {
|
||||
var items: [TravelAlbumPhoneAlbumImportItem] = []
|
||||
for (index, result) in results.enumerated() {
|
||||
if let item = await Self.loadPhoneAlbumImportItem(from: result, index: index) {
|
||||
items.append(item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func loadPhoneAlbumImportItem(
|
||||
from result: PHPickerResult,
|
||||
index: Int
|
||||
) async -> TravelAlbumPhoneAlbumImportItem? {
|
||||
let provider = result.itemProvider
|
||||
let typeIdentifier = preferredImageTypeIdentifier(from: provider)
|
||||
return await withCheckedContinuation { continuation in
|
||||
provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in
|
||||
guard let data, !data.isEmpty else {
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
continuation.resume(
|
||||
returning: TravelAlbumPhoneAlbumImportItem(
|
||||
data: data,
|
||||
fileName: importFileName(provider: provider, typeIdentifier: typeIdentifier, index: index),
|
||||
createdAt: Date()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String {
|
||||
provider.registeredTypeIdentifiers.first { identifier in
|
||||
UTType(identifier)?.conforms(to: .image) == true
|
||||
} ?? UTType.image.identifier
|
||||
}
|
||||
|
||||
private static func importFileName(provider: NSItemProvider, typeIdentifier: String, index: Int) -> String {
|
||||
let fallback = "album_image_\(Int(Date().timeIntervalSince1970))_\(index)"
|
||||
let rawName = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let baseName = rawName?.isEmpty == false ? rawName! : fallback
|
||||
guard URL(fileURLWithPath: baseName).pathExtension.isEmpty else {
|
||||
return baseName
|
||||
}
|
||||
let ext = UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg"
|
||||
return "\(baseName).\(ext)"
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 指定上传选项弹窗。
|
||||
private final class WiredSpecifyUploadViewController: UIViewController {
|
||||
var onConfirm: ((TravelAlbumOTGSpecifyUploadOption) -> Void)?
|
||||
|
||||
private let options: [TravelAlbumOTGSpecifyUploadOption]
|
||||
private var selectedOption: TravelAlbumOTGSpecifyUploadOption
|
||||
private var optionRows: [WiredSpecifyUploadOptionRow] = []
|
||||
private let headerView = UIView()
|
||||
private let cancelTitleButton = UIButton(type: .system)
|
||||
private let titleLabel = UILabel()
|
||||
private let optionsStack = UIStackView()
|
||||
private let bottomButtonsStack = UIStackView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
|
||||
init(options: [TravelAlbumOTGSpecifyUploadOption]) {
|
||||
let resolvedOptions = options.isEmpty ? TravelAlbumOTGSpecifyUploadOption.allCases : options
|
||||
self.options = resolvedOptions
|
||||
selectedOption = resolvedOptions[0]
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
cancelTitleButton.setTitle("取消", for: .normal)
|
||||
cancelTitleButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
cancelTitleButton.titleLabel?.font = .systemFont(ofSize: 16)
|
||||
cancelTitleButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
|
||||
titleLabel.text = "指定上传"
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
optionsStack.axis = .vertical
|
||||
optionsStack.spacing = 12
|
||||
for option in options {
|
||||
let row = WiredSpecifyUploadOptionRow(option: option)
|
||||
row.apply(selected: option == selectedOption)
|
||||
row.onTap = { [weak self] option in self?.selectOption(option) }
|
||||
optionsStack.addArrangedSubview(row)
|
||||
row.snp.makeConstraints { make in
|
||||
make.height.equalTo(52)
|
||||
}
|
||||
optionRows.append(row)
|
||||
}
|
||||
|
||||
bottomButtonsStack.axis = .horizontal
|
||||
bottomButtonsStack.spacing = 12
|
||||
bottomButtonsStack.distribution = .fillEqually
|
||||
configureActionButton(cancelButton, title: "取消", filled: false)
|
||||
configureActionButton(confirmButton, title: "确认上传", filled: true)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(headerView)
|
||||
headerView.addSubview(cancelTitleButton)
|
||||
headerView.addSubview(titleLabel)
|
||||
view.addSubview(optionsStack)
|
||||
view.addSubview(bottomButtonsStack)
|
||||
bottomButtonsStack.addArrangedSubview(cancelButton)
|
||||
bottomButtonsStack.addArrangedSubview(confirmButton)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
headerView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(52)
|
||||
}
|
||||
cancelTitleButton.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.height.equalTo(40)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(cancelTitleButton.snp.trailing).offset(12)
|
||||
}
|
||||
optionsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerView.snp.bottom)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
bottomButtonsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(optionsStack.snp.bottom).offset(20)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(48)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureActionButton(_ button: UIButton, title: String, filled: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.layer.cornerRadius = 10
|
||||
if filled {
|
||||
button.backgroundColor = AppColor.primary
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
} else {
|
||||
button.backgroundColor = .white
|
||||
button.setTitleColor(AppColor.primary, for: .normal)
|
||||
button.layer.borderWidth = 1
|
||||
button.layer.borderColor = AppColor.primary.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private func selectOption(_ option: TravelAlbumOTGSpecifyUploadOption) {
|
||||
selectedOption = option
|
||||
optionRows.forEach { row in
|
||||
row.apply(selected: row.option == option)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
let option = selectedOption
|
||||
dismiss(animated: true) { [onConfirm] in
|
||||
onConfirm?(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 指定上传单选行。
|
||||
private final class WiredSpecifyUploadOptionRow: UIControl {
|
||||
let option: TravelAlbumOTGSpecifyUploadOption
|
||||
var onTap: ((TravelAlbumOTGSpecifyUploadOption) -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let radioView = UIImageView()
|
||||
|
||||
init(option: TravelAlbumOTGSpecifyUploadOption) {
|
||||
self.option = option
|
||||
super.init(frame: .zero)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(selected: Bool) {
|
||||
radioView.image = UIImage(systemName: selected ? "largecircle.fill.circle" : "circle")
|
||||
radioView.tintColor = selected ? AppColor.textPrimary : AppColor.border
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 10
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = AppColor.border.cgColor
|
||||
|
||||
titleLabel.text = option.title
|
||||
titleLabel.font = .systemFont(ofSize: 15)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
radioView.contentMode = .scaleAspectFit
|
||||
addTarget(self, action: #selector(rowTapped), for: .touchUpInside)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(radioView)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(14)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(radioView.snp.leading).offset(-10)
|
||||
}
|
||||
radioView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-14)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func rowTapped() {
|
||||
onTap?(option)
|
||||
}
|
||||
}
|
||||
|
||||
extension WiredCameraTransferViewController: UICollectionViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView === collectionView else { return }
|
||||
syncSidebarSelectionWithCurrentScrollPosition()
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
if viewModel.selectUploadMode {
|
||||
viewModel.toggleTransferPhotoSelection(photoId: item.id)
|
||||
} else if item.status == .failed {
|
||||
viewModel.retryPhoto(photoId: item.id)
|
||||
} else {
|
||||
presentPhotoPreview(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -783,9 +1192,13 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.layer.cornerRadius = 3
|
||||
statusLabel.clipsToBounds = true
|
||||
statusLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
sizeLabel.font = .systemFont(ofSize: 10)
|
||||
sizeLabel.textColor = AppColor.textTertiary
|
||||
progressView.progressTintColor = AppColor.primary
|
||||
@ -909,6 +1322,7 @@ private final class WiredTransferTimeSidebarView: UIView {
|
||||
private let headerButton = UIButton(type: .system)
|
||||
private let scrollView = UIScrollView()
|
||||
private let stackView = UIStackView()
|
||||
private weak var selectedRowView: TimeSlotRowView?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@ -921,6 +1335,7 @@ private final class WiredTransferTimeSidebarView: UIView {
|
||||
}
|
||||
|
||||
func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) {
|
||||
selectedRowView = nil
|
||||
stackView.arrangedSubviews.forEach { view in
|
||||
stackView.removeArrangedSubview(view)
|
||||
view.removeFromSuperview()
|
||||
@ -945,7 +1360,11 @@ private final class WiredTransferTimeSidebarView: UIView {
|
||||
|
||||
for slot in group.slots {
|
||||
let row = TimeSlotRowView()
|
||||
row.apply(slot: slot, selected: slot.id == selectedSlotId)
|
||||
let selected = slot.id == selectedSlotId
|
||||
row.apply(slot: slot, selected: selected)
|
||||
if selected {
|
||||
selectedRowView = row
|
||||
}
|
||||
row.onTap = { [weak self] in self?.onTimeSlotSelected?(slot.id) }
|
||||
stackView.addArrangedSubview(row)
|
||||
row.snp.makeConstraints { make in
|
||||
@ -953,6 +1372,7 @@ private final class WiredTransferTimeSidebarView: UIView {
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollSelectedRowIntoVisible()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
@ -988,6 +1408,13 @@ private final class WiredTransferTimeSidebarView: UIView {
|
||||
@objc private func toggleTapped() {
|
||||
onToggleSidebar?()
|
||||
}
|
||||
|
||||
private func scrollSelectedRowIntoVisible() {
|
||||
guard let selectedRowView else { return }
|
||||
layoutIfNeeded()
|
||||
let rowFrame = selectedRowView.convert(selectedRowView.bounds, to: stackView)
|
||||
scrollView.scrollRectToVisible(rowFrame.insetBy(dx: 0, dy: -8), animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TimeSlotRowView: UIControl {
|
||||
|
||||
Reference in New Issue
Block a user