重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
1506 lines
62 KiB
Swift
1506 lines
62 KiB
Swift
//
|
||
// WiredCameraTransferViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Kingfisher
|
||
import PhotosUI
|
||
import SnapKit
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
|
||
private enum WiredTransferMetrics {
|
||
static let sidebarWidth: CGFloat = 76
|
||
static let rowHeight: CGFloat = 66
|
||
static let sectionHeaderHeight: CGFloat = 28
|
||
static let selectAllBarHeight: CGFloat = 44
|
||
}
|
||
|
||
private struct WiredTransferListSection: Hashable {
|
||
let id: String
|
||
let title: String
|
||
|
||
init(_ section: TravelAlbumOTGPhotoSection) {
|
||
id = section.slotId
|
||
title = section.headerTitle
|
||
}
|
||
}
|
||
|
||
/// 有线相机传输页,对齐 Android OTG 页面并接入真实 ImageCaptureCore 传输。
|
||
final class WiredCameraTransferViewController: BaseViewController {
|
||
private let viewModel: WiredCameraTransferViewModel
|
||
|
||
private let titleLabel = UILabel()
|
||
private let phoneLabel = UILabel()
|
||
private let statusCard = UIView()
|
||
private let storagePanel = UIView()
|
||
private let storageTitleLabel = UILabel()
|
||
private let storageValueLabel = UILabel()
|
||
private let statusLabel = UILabel()
|
||
private let refreshButton = UIButton(type: .system)
|
||
private let helpLabel = UILabel()
|
||
private let chipsStack = UIStackView()
|
||
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()
|
||
private let selectAllTitleLabel = UILabel()
|
||
private let selectAllCountLabel = UILabel()
|
||
private let photoContentView = UIView()
|
||
private let sidebarView = WiredTransferTimeSidebarView()
|
||
private let sidebarDivider = UIView()
|
||
private let listContainerView = UIView()
|
||
private let expandSidebarButton = UIButton(type: .system)
|
||
private let emptyLabel = UILabel()
|
||
private var collectionView: UICollectionView!
|
||
private var dataSource: UICollectionViewDiffableDataSource<WiredTransferListSection, TravelAlbumOTGPhotoItem>!
|
||
private var currentSections: [WiredTransferListSection] = []
|
||
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
|
||
private var previousNavigationBarStyle: (tintColor: UIColor?, barStyle: UIBarStyle, isTranslucent: Bool)?
|
||
|
||
init(viewModel: WiredCameraTransferViewModel) {
|
||
self.viewModel = viewModel
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func setupNavigationBar() {
|
||
navigationItem.largeTitleDisplayMode = .never
|
||
configureBlueNavigationBarAppearance()
|
||
|
||
let titleStack = UIStackView(arrangedSubviews: [titleLabel, phoneLabel])
|
||
titleStack.axis = .vertical
|
||
titleStack.alignment = .center
|
||
titleStack.spacing = 1
|
||
navigationItem.titleView = titleStack
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
applyViewModel()
|
||
}
|
||
|
||
override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
applyNavigationBarStyle()
|
||
viewModel.start()
|
||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
viewModel.stop()
|
||
restoreNavigationBarStyle()
|
||
}
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = AppColor.pageBackground
|
||
titleLabel.text = viewModel.albumTitle
|
||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||
titleLabel.textColor = .white
|
||
titleLabel.textAlignment = .center
|
||
phoneLabel.text = viewModel.headerPhone
|
||
phoneLabel.font = .systemFont(ofSize: 11)
|
||
phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92)
|
||
phoneLabel.textAlignment = .center
|
||
|
||
statusCard.backgroundColor = .white
|
||
statusCard.layer.cornerRadius = 12
|
||
statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor
|
||
statusCard.layer.shadowOpacity = 1
|
||
statusCard.layer.shadowRadius = 8
|
||
statusCard.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||
|
||
storagePanel.layer.cornerRadius = 12
|
||
storagePanel.layer.borderColor = AppColor.border.cgColor
|
||
storagePanel.layer.borderWidth = 3
|
||
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
|
||
statusLabel.clipsToBounds = true
|
||
statusLabel.textAlignment = .center
|
||
refreshButton.setTitleColor(.white, for: .normal)
|
||
refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
|
||
refreshButton.backgroundColor = AppColor.primary
|
||
refreshButton.layer.cornerRadius = 8
|
||
helpLabel.font = .systemFont(ofSize: 10)
|
||
helpLabel.numberOfLines = 3
|
||
helpLabel.isUserInteractionEnabled = true
|
||
|
||
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.setConfigurationContentInsets(
|
||
NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 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
|
||
selectAllIconView.contentMode = .scaleAspectFit
|
||
selectAllTitleLabel.text = "全选"
|
||
selectAllTitleLabel.font = .systemFont(ofSize: 13)
|
||
selectAllTitleLabel.textColor = AppColor.textPrimary
|
||
selectAllCountLabel.font = .systemFont(ofSize: 12)
|
||
selectAllCountLabel.textColor = AppColor.textSecondary
|
||
selectAllCountLabel.textAlignment = .right
|
||
sidebarDivider.backgroundColor = AppColor.border
|
||
listContainerView.backgroundColor = .white
|
||
expandSidebarButton.setImage(UIImage(systemName: "chevron.right"), for: .normal)
|
||
expandSidebarButton.tintColor = AppColor.textSecondary
|
||
expandSidebarButton.backgroundColor = UIColor(hex: 0xF5F6F8)
|
||
expandSidebarButton.layer.cornerRadius = 8
|
||
expandSidebarButton.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||
|
||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||
collectionView.backgroundColor = .white
|
||
collectionView.delegate = self
|
||
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0)
|
||
collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier)
|
||
collectionView.register(
|
||
WiredTransferSectionHeaderView.self,
|
||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||
withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier
|
||
)
|
||
dataSource = UICollectionViewDiffableDataSource<WiredTransferListSection, TravelAlbumOTGPhotoItem>(collectionView: collectionView) {
|
||
[weak self] collectionView, indexPath, item in
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as! WiredTransferPhotoCell
|
||
self?.configurePhotoCell(cell, item: item)
|
||
return cell
|
||
}
|
||
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||
guard kind == UICollectionView.elementKindSectionHeader else { return nil }
|
||
let header = collectionView.dequeueReusableSupplementaryView(
|
||
ofKind: kind,
|
||
withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier,
|
||
for: indexPath
|
||
) as! WiredTransferSectionHeaderView
|
||
header.apply(title: self?.currentSections[safe: indexPath.section]?.title ?? "")
|
||
return header
|
||
}
|
||
|
||
emptyLabel.text = "暂无传输文件"
|
||
emptyLabel.font = .systemFont(ofSize: 14)
|
||
emptyLabel.textColor = AppColor.textTertiary
|
||
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(statusCard)
|
||
statusCard.addSubview(storagePanel)
|
||
storagePanel.addSubview(storageTitleLabel)
|
||
storagePanel.addSubview(storageValueLabel)
|
||
statusCard.addSubview(statusLabel)
|
||
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)
|
||
selectAllBar.addSubview(selectAllTitleLabel)
|
||
selectAllBar.addSubview(selectAllCountLabel)
|
||
photoPanelView.addSubview(photoContentView)
|
||
photoContentView.addSubview(sidebarView)
|
||
photoContentView.addSubview(sidebarDivider)
|
||
photoContentView.addSubview(listContainerView)
|
||
listContainerView.addSubview(collectionView)
|
||
listContainerView.addSubview(expandSidebarButton)
|
||
view.addSubview(emptyLabel)
|
||
view.addSubview(bottomBar)
|
||
bottomBar.addSubview(bottomButtonsStack)
|
||
modeButton.addSubview(modeChevronView)
|
||
bottomButtonsStack.addArrangedSubview(batchButton)
|
||
bottomButtonsStack.addArrangedSubview(specifyButton)
|
||
bottomButtonsStack.addArrangedSubview(albumImportButton)
|
||
bottomButtonsStack.addArrangedSubview(historyImportButton)
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
statusCard.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(14)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
}
|
||
storagePanel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(14)
|
||
make.leading.equalToSuperview().offset(14)
|
||
make.width.equalTo(96)
|
||
make.height.equalTo(76)
|
||
}
|
||
storageTitleLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(10)
|
||
make.leading.trailing.equalToSuperview().inset(8)
|
||
}
|
||
storageValueLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(storageTitleLabel.snp.bottom).offset(6)
|
||
make.leading.trailing.equalTo(storageTitleLabel)
|
||
}
|
||
statusLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(16)
|
||
make.leading.equalTo(storagePanel.snp.trailing).offset(8)
|
||
make.trailing.lessThanOrEqualTo(refreshButton.snp.leading).offset(-6)
|
||
make.height.equalTo(22)
|
||
make.width.greaterThanOrEqualTo(84)
|
||
}
|
||
refreshButton.snp.makeConstraints { make in
|
||
make.centerY.equalTo(statusLabel)
|
||
make.trailing.equalToSuperview().offset(-14)
|
||
make.height.equalTo(34)
|
||
make.width.greaterThanOrEqualTo(64)
|
||
}
|
||
helpLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(statusLabel.snp.bottom).offset(4)
|
||
make.leading.equalTo(statusLabel)
|
||
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(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()
|
||
}
|
||
bottomButtonsStack.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(12)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
make.height.equalTo(44)
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||
}
|
||
photoPanelView.snp.makeConstraints { make in
|
||
make.top.equalTo(statusCard.snp.bottom).offset(14)
|
||
make.leading.trailing.equalToSuperview()
|
||
make.bottom.equalTo(bottomBar.snp.top)
|
||
}
|
||
selectAllBar.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
selectAllBarHeightConstraint = make.height.equalTo(WiredTransferMetrics.selectAllBarHeight).constraint
|
||
}
|
||
selectAllIconView.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(10)
|
||
make.centerY.equalToSuperview()
|
||
make.size.equalTo(20)
|
||
}
|
||
selectAllTitleLabel.snp.makeConstraints { make in
|
||
make.leading.equalTo(selectAllIconView.snp.trailing).offset(8)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
selectAllCountLabel.snp.makeConstraints { make in
|
||
make.leading.greaterThanOrEqualTo(selectAllTitleLabel.snp.trailing).offset(8)
|
||
make.trailing.equalToSuperview().offset(-10)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
photoContentView.snp.makeConstraints { make in
|
||
make.top.equalTo(selectAllBar.snp.bottom)
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
collectionView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
expandSidebarButton.snp.makeConstraints { make in
|
||
make.leading.centerY.equalToSuperview()
|
||
make.width.equalTo(24)
|
||
make.height.equalTo(48)
|
||
}
|
||
emptyLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(statusCard.snp.bottom)
|
||
make.leading.trailing.equalToSuperview()
|
||
make.bottom.equalTo(bottomBar.snp.top)
|
||
}
|
||
updateSidebarLayout()
|
||
}
|
||
|
||
override func bindActions() {
|
||
viewModel.onStateChange = { [weak self] in
|
||
Task { @MainActor in self?.applyViewModel() }
|
||
}
|
||
viewModel.onShowMessage = { [weak self] message in
|
||
Task { @MainActor in self?.showToast(message) }
|
||
}
|
||
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)
|
||
sidebarView.onToggleSidebar = { [weak self] in self?.viewModel.toggleSidebar() }
|
||
sidebarView.onTimeSlotSelected = { [weak self] slotId in
|
||
self?.pendingScrollSlotId = slotId
|
||
self?.viewModel.selectTimeSlot(id: slotId)
|
||
}
|
||
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)))
|
||
}
|
||
|
||
@MainActor
|
||
private func applyViewModel() {
|
||
storageTitleLabel.text = viewModel.deviceModelName
|
||
storageValueLabel.text = viewModel.availableStorageText
|
||
statusLabel.text = viewModel.cameraStatusText
|
||
let isFailed = viewModel.cameraStatusText.contains("失败") || viewModel.cameraStatusText.contains("未连接")
|
||
statusLabel.textColor = isFailed ? AppColor.danger : AppColor.primary
|
||
statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10)
|
||
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
|
||
|
||
retouchButton.setTitle(viewModel.retouchOption, for: .normal)
|
||
formatButton.setTitle("JPG", for: .normal)
|
||
modeButton.setTitle(viewModel.transferModeOption, for: .normal)
|
||
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
|
||
|
||
rebuildStats()
|
||
let sections = viewModel.photoSections
|
||
currentSections = sections.map(WiredTransferListSection.init)
|
||
var snapshot = NSDiffableDataSourceSnapshot<WiredTransferListSection, TravelAlbumOTGPhotoItem>()
|
||
snapshot.appendSections(currentSections)
|
||
for (index, section) in sections.enumerated() {
|
||
snapshot.appendItems(section.photos, toSection: currentSections[index])
|
||
}
|
||
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
|
||
self?.reconfigureVisiblePhotoCells()
|
||
self?.scrollToPendingTimeSlotIfNeeded()
|
||
}
|
||
|
||
sidebarView.apply(
|
||
groups: viewModel.sidebarGroups,
|
||
selectedSlotId: viewModel.selectedTimeSlotId
|
||
)
|
||
updateSelectAllBar()
|
||
updateSidebarLayout()
|
||
emptyLabel.isHidden = !sections.isEmpty
|
||
photoPanelView.isHidden = sections.isEmpty
|
||
if viewModel.selectUploadMode {
|
||
let count = viewModel.selectedPhotoIds.count
|
||
batchButton.setTitle(count > 0 ? "上传选中(\(count))" : "取消", for: .normal)
|
||
} else {
|
||
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() {
|
||
let showSelectAll = viewModel.selectUploadMode && !viewModel.photoSections.isEmpty
|
||
selectAllBar.isHidden = !showSelectAll
|
||
selectAllBarHeightConstraint?.update(offset: showSelectAll ? WiredTransferMetrics.selectAllBarHeight : 0)
|
||
let allSelected = viewModel.allVisibleSelectablePhotosSelected
|
||
selectAllIconView.image = UIImage(systemName: allSelected ? "checkmark.circle.fill" : "circle")
|
||
selectAllIconView.tintColor = allSelected ? AppColor.primary : AppColor.textTertiary
|
||
selectAllCountLabel.text = "已选 \(viewModel.selectedVisibleUploadCount) / \(viewModel.selectableVisiblePhotoIds.count)"
|
||
selectAllBar.isEnabled = !viewModel.selectableVisiblePhotoIds.isEmpty
|
||
selectAllBar.alpha = selectAllBar.isEnabled ? 1 : 0.55
|
||
}
|
||
|
||
private func updateSidebarLayout() {
|
||
let showSidebar = viewModel.sidebarExpanded && !viewModel.sidebarGroups.isEmpty
|
||
sidebarView.isHidden = !showSidebar
|
||
sidebarDivider.isHidden = !showSidebar
|
||
expandSidebarButton.isHidden = viewModel.sidebarExpanded || viewModel.sidebarGroups.isEmpty
|
||
sidebarView.snp.remakeConstraints { make in
|
||
make.top.leading.bottom.equalToSuperview()
|
||
make.width.equalTo(showSidebar ? WiredTransferMetrics.sidebarWidth : 0)
|
||
}
|
||
sidebarDivider.snp.remakeConstraints { make in
|
||
make.top.bottom.equalToSuperview()
|
||
make.leading.equalTo(sidebarView.snp.trailing)
|
||
make.width.equalTo(showSidebar ? 1 : 0)
|
||
}
|
||
listContainerView.snp.remakeConstraints { make in
|
||
make.top.bottom.trailing.equalToSuperview()
|
||
make.leading.equalTo(sidebarDivider.snp.trailing)
|
||
}
|
||
}
|
||
|
||
private func scrollToPendingTimeSlotIfNeeded() {
|
||
guard let slotId = pendingScrollSlotId,
|
||
let sectionIndex = currentSections.firstIndex(where: { $0.id == slotId }),
|
||
collectionView.numberOfItems(inSection: sectionIndex) > 0 else {
|
||
pendingScrollSlotId = nil
|
||
return
|
||
}
|
||
collectionView.scrollToItem(
|
||
at: IndexPath(item: 0, section: sectionIndex),
|
||
at: .top,
|
||
animated: true
|
||
)
|
||
pendingScrollSlotId = nil
|
||
}
|
||
|
||
private func configureBlueNavigationBarAppearance() {
|
||
let appearance = UINavigationBarAppearance()
|
||
appearance.configureWithOpaqueBackground()
|
||
appearance.backgroundColor = AppColor.primary
|
||
appearance.shadowColor = .clear
|
||
appearance.titleTextAttributes = [
|
||
.foregroundColor: UIColor.white,
|
||
.font: UIFont.systemFont(ofSize: 17, weight: .semibold),
|
||
]
|
||
|
||
navigationItem.standardAppearance = appearance
|
||
navigationItem.scrollEdgeAppearance = appearance
|
||
navigationItem.compactAppearance = appearance
|
||
}
|
||
|
||
private func applyNavigationBarStyle() {
|
||
guard let navigationBar = navigationController?.navigationBar else { return }
|
||
if previousNavigationBarStyle == nil {
|
||
previousNavigationBarStyle = (navigationBar.tintColor, navigationBar.barStyle, navigationBar.isTranslucent)
|
||
}
|
||
navigationBar.tintColor = .white
|
||
navigationBar.barStyle = .black
|
||
navigationBar.isTranslucent = false
|
||
}
|
||
|
||
private func restoreNavigationBarStyle() {
|
||
guard let previousNavigationBarStyle, let navigationBar = navigationController?.navigationBar else {
|
||
return
|
||
}
|
||
navigationBar.tintColor = previousNavigationBarStyle.tintColor
|
||
navigationBar.barStyle = previousNavigationBarStyle.barStyle
|
||
navigationBar.isTranslucent = previousNavigationBarStyle.isTranslucent
|
||
self.previousNavigationBarStyle = nil
|
||
}
|
||
|
||
private func configurePhotoCell(_ cell: WiredTransferPhotoCell, item: TravelAlbumOTGPhotoItem) {
|
||
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?.viewModel.deletePhoto(photoId: item.id) }
|
||
}
|
||
|
||
private func reconfigureVisiblePhotoCells() {
|
||
for indexPath in collectionView.indexPathsForVisibleItems {
|
||
guard let cell = collectionView.cellForItem(at: indexPath) as? WiredTransferPhotoCell,
|
||
let item = dataSource.itemIdentifier(for: indexPath) else {
|
||
continue
|
||
}
|
||
configurePhotoCell(cell, item: item)
|
||
}
|
||
}
|
||
|
||
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)
|
||
view.removeFromSuperview()
|
||
}
|
||
for tab in TravelAlbumOTGTransferTab.allCases {
|
||
let selected = viewModel.selectedTab == tab
|
||
let button = makeStatButton(
|
||
title: tab.title,
|
||
count: viewModel.tabCounts[tab.rawValue],
|
||
selected: selected,
|
||
danger: tab == .failed
|
||
)
|
||
button.tag = tab.rawValue
|
||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||
statsCard.addArrangedSubview(button)
|
||
}
|
||
statsCard.bringSubviewToFront(firstStatsDivider)
|
||
statsCard.bringSubviewToFront(secondStatsDivider)
|
||
}
|
||
|
||
private func configureChipButton(_ button: UIButton) {
|
||
button.titleLabel?.font = .systemFont(ofSize: 11)
|
||
button.setTitleColor(AppColor.textTertiary, for: .normal)
|
||
button.backgroundColor = AppColor.pageBackground
|
||
button.layer.cornerRadius = 8
|
||
button.layer.borderWidth = 1
|
||
button.layer.borderColor = AppColor.border.cgColor
|
||
button.contentHorizontalAlignment = .leading
|
||
button.setConfigurationContentInsets(
|
||
NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10)
|
||
)
|
||
}
|
||
|
||
private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton {
|
||
var config = UIButton.Configuration.plain()
|
||
config.title = "\(count)"
|
||
config.subtitle = title
|
||
config.titleAlignment = .center
|
||
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.contentVerticalAlignment = .center
|
||
return button
|
||
}
|
||
|
||
private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) {
|
||
button.setTitle(title, for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: 15)
|
||
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 makeLayout() -> UICollectionViewCompositionalLayout {
|
||
UICollectionViewCompositionalLayout { _, _ in
|
||
let itemSize = NSCollectionLayoutSize(
|
||
widthDimension: .fractionalWidth(1),
|
||
heightDimension: .estimated(WiredTransferMetrics.rowHeight)
|
||
)
|
||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||
let groupSize = NSCollectionLayoutSize(
|
||
widthDimension: .fractionalWidth(1),
|
||
heightDimension: .estimated(WiredTransferMetrics.rowHeight)
|
||
)
|
||
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
|
||
let section = NSCollectionLayoutSection(group: group)
|
||
let headerSize = NSCollectionLayoutSize(
|
||
widthDimension: .fractionalWidth(1),
|
||
heightDimension: .absolute(WiredTransferMetrics.sectionHeaderHeight)
|
||
)
|
||
let header = NSCollectionLayoutBoundarySupplementaryItem(
|
||
layoutSize: headerSize,
|
||
elementKind: UICollectionView.elementKindSectionHeader,
|
||
alignment: .top
|
||
)
|
||
header.pinToVisibleBounds = true
|
||
section.boundarySupplementaryItems = [header]
|
||
return section
|
||
}
|
||
}
|
||
|
||
private func helpText(_ sonyHint: String?) -> NSAttributedString {
|
||
let text = NSMutableAttributedString(
|
||
string: sonyHint ?? "未连接,查看",
|
||
attributes: [.foregroundColor: AppColor.textSecondary]
|
||
)
|
||
if sonyHint == nil {
|
||
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
|
||
}
|
||
return text
|
||
}
|
||
|
||
@objc private func refreshTapped() {
|
||
viewModel.refreshCameraFiles()
|
||
}
|
||
|
||
@objc private func batchTapped() {
|
||
viewModel.onBatchUploadButtonClick()
|
||
}
|
||
|
||
@objc private func historyImportTapped() {
|
||
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
|
||
viewModel.pauseLiveTransferForHistoricalImport()
|
||
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
|
||
importController.onImportFinished = { [weak self] importedCount, failedCount, importedPhotoIds in
|
||
guard let self else { return }
|
||
self.viewModel.handleNewlyImportedPhotoIds(importedPhotoIds)
|
||
self.viewModel.start()
|
||
if failedCount > 0 {
|
||
self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败")
|
||
} else {
|
||
self.showToast("已导入 \(importedCount) 张照片")
|
||
}
|
||
}
|
||
importController.onImportCancelled = { [weak self] in
|
||
self?.viewModel.start()
|
||
}
|
||
let navigationController = UINavigationController(rootViewController: importController)
|
||
navigationController.modalPresentationStyle = .pageSheet
|
||
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() {
|
||
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() {
|
||
viewModel.toggleVisibleSelectablePhotos()
|
||
}
|
||
|
||
@objc private func toggleSidebarTapped() {
|
||
viewModel.toggleSidebar()
|
||
}
|
||
|
||
@objc private func tabTapped(_ sender: UIButton) {
|
||
guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return }
|
||
viewModel.selectTab(tab)
|
||
}
|
||
|
||
@objc private func helpTapped() {
|
||
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)
|
||
let fileName = importFileName(
|
||
suggestedName: provider.suggestedName,
|
||
typeIdentifier: typeIdentifier,
|
||
index: index
|
||
)
|
||
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: fileName,
|
||
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(suggestedName: String?, typeIdentifier: String, index: Int) -> String {
|
||
let fallback = "album_image_\(Int(Date().timeIntervalSince1970))_\(index)"
|
||
let rawName = 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 {
|
||
presentPhotoPreview(item)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 有线传输照片列表时间段 Header。
|
||
private final class WiredTransferSectionHeaderView: UICollectionReusableView {
|
||
static let reuseIdentifier = "WiredTransferSectionHeaderView"
|
||
|
||
private let titleLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = AppColor.pageBackground
|
||
titleLabel.font = .systemFont(ofSize: 11)
|
||
titleLabel.textColor = AppColor.textSecondary
|
||
addSubview(titleLabel)
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview().inset(10)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(title: String) {
|
||
titleLabel.text = title
|
||
}
|
||
}
|
||
|
||
/// 有线传输照片列表 Cell。
|
||
private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||
static let reuseIdentifier = "WiredTransferPhotoCell"
|
||
private static let previewImageSize = CGSize(width: 96, height: 96)
|
||
|
||
private let selectionIconView = UIImageView()
|
||
private let imageView = UIImageView()
|
||
private let statusLabel = UILabel()
|
||
private let titleLabel = UILabel()
|
||
private let sizeLabel = UILabel()
|
||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||
private let menuButton = UIButton(type: .system)
|
||
private let separatorView = UIView()
|
||
|
||
var onRetry: (() -> Void)?
|
||
var onDelete: (() -> Void)?
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUI()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func prepareForReuse() {
|
||
super.prepareForReuse()
|
||
imageView.kf.cancelDownloadTask()
|
||
imageView.image = nil
|
||
onRetry = nil
|
||
onDelete = nil
|
||
menuButton.menu = nil
|
||
}
|
||
|
||
func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) {
|
||
let canSelect = item.canSelectForUpload
|
||
let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1
|
||
if let url = item.thumbnailURL {
|
||
let processor = DownsamplingImageProcessor(size: Self.previewImageSize)
|
||
imageView.kf.setImage(
|
||
with: url,
|
||
placeholder: UIImage(systemName: "photo"),
|
||
options: [
|
||
.processor(processor),
|
||
.scaleFactor(UIScreen.main.scale),
|
||
.backgroundDecode
|
||
]
|
||
)
|
||
} else {
|
||
imageView.image = UIImage(systemName: "photo")
|
||
imageView.tintColor = AppColor.textTertiary
|
||
}
|
||
imageView.alpha = rowAlpha
|
||
titleLabel.text = item.fileName
|
||
titleLabel.alpha = rowAlpha
|
||
sizeLabel.text = item.fileSizeText
|
||
sizeLabel.alpha = rowAlpha
|
||
statusLabel.text = statusText(item.status)
|
||
statusLabel.textColor = statusTextColor(item.status)
|
||
statusLabel.backgroundColor = statusBackgroundColor(item.status)
|
||
progressView.isHidden = item.status != .uploading && item.status != .transferring
|
||
progressView.progress = Float(item.progress) / 100.0
|
||
selectionIconView.isHidden = !selectionMode
|
||
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
|
||
menuButton.isHidden = selectionMode
|
||
menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending)
|
||
updateImageConstraints(selectionMode: selectionMode)
|
||
}
|
||
|
||
private func setupUI() {
|
||
contentView.backgroundColor = .white
|
||
selectionIconView.contentMode = .scaleAspectFit
|
||
imageView.contentMode = .scaleAspectFill
|
||
imageView.clipsToBounds = true
|
||
imageView.layer.cornerRadius = 6
|
||
statusLabel.font = .systemFont(ofSize: 9)
|
||
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
|
||
progressView.trackTintColor = AppColor.border
|
||
menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal)
|
||
menuButton.tintColor = AppColor.textSecondary
|
||
menuButton.showsMenuAsPrimaryAction = true
|
||
separatorView.backgroundColor = AppColor.border
|
||
|
||
contentView.addSubview(selectionIconView)
|
||
contentView.addSubview(imageView)
|
||
contentView.addSubview(titleLabel)
|
||
contentView.addSubview(statusLabel)
|
||
contentView.addSubview(sizeLabel)
|
||
contentView.addSubview(progressView)
|
||
contentView.addSubview(menuButton)
|
||
contentView.addSubview(separatorView)
|
||
|
||
selectionIconView.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(10)
|
||
make.centerY.equalToSuperview()
|
||
make.size.equalTo(20)
|
||
}
|
||
updateImageConstraints(selectionMode: true)
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(imageView).offset(1)
|
||
make.leading.equalTo(imageView.snp.trailing).offset(8)
|
||
make.trailing.equalTo(statusLabel.snp.leading).offset(-4)
|
||
}
|
||
statusLabel.snp.makeConstraints { make in
|
||
make.centerY.equalTo(titleLabel)
|
||
make.trailing.equalTo(menuButton.snp.leading).offset(-4)
|
||
make.height.equalTo(17)
|
||
make.width.greaterThanOrEqualTo(44)
|
||
}
|
||
sizeLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(5)
|
||
make.leading.equalTo(titleLabel)
|
||
make.trailing.equalToSuperview().offset(-10)
|
||
}
|
||
progressView.snp.makeConstraints { make in
|
||
make.leading.equalTo(titleLabel)
|
||
make.trailing.equalToSuperview().offset(-10)
|
||
make.top.equalTo(sizeLabel.snp.bottom).offset(7)
|
||
make.height.equalTo(2)
|
||
}
|
||
menuButton.snp.makeConstraints { make in
|
||
make.centerY.equalTo(titleLabel)
|
||
make.trailing.equalToSuperview().offset(-4)
|
||
make.size.equalTo(32)
|
||
}
|
||
separatorView.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview().inset(10)
|
||
make.bottom.equalToSuperview()
|
||
make.height.equalTo(1 / UIScreen.main.scale)
|
||
}
|
||
}
|
||
|
||
private func makeMenu(canRetry: Bool) -> UIMenu {
|
||
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
|
||
self?.onRetry?()
|
||
}
|
||
retry.attributes = canRetry ? [] : [.disabled]
|
||
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||
self?.onDelete?()
|
||
}
|
||
return UIMenu(children: [retry, delete])
|
||
}
|
||
|
||
private func updateImageConstraints(selectionMode: Bool) {
|
||
imageView.snp.remakeConstraints { make in
|
||
if selectionMode {
|
||
make.leading.equalTo(selectionIconView.snp.trailing).offset(8)
|
||
} else {
|
||
make.leading.equalToSuperview().offset(10)
|
||
}
|
||
make.top.equalToSuperview().offset(8)
|
||
make.size.equalTo(48)
|
||
make.bottom.lessThanOrEqualToSuperview().offset(-8)
|
||
}
|
||
}
|
||
|
||
private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String {
|
||
switch status {
|
||
case .pending: return "待上传"
|
||
case .transferring: return "传输中"
|
||
case .uploading: return "上传中"
|
||
case .uploaded: return "已上传"
|
||
case .failed: return "上传失败"
|
||
}
|
||
}
|
||
|
||
private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
|
||
switch status {
|
||
case .pending: return AppColor.textSecondary
|
||
case .transferring, .uploading: return AppColor.primary
|
||
case .uploaded: return UIColor(hex: 0x16A34A)
|
||
case .failed: return AppColor.danger
|
||
}
|
||
}
|
||
|
||
private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
|
||
switch status {
|
||
case .pending:
|
||
return AppColor.pageBackground
|
||
case .transferring, .uploading:
|
||
return AppColor.primary.withAlphaComponent(0.12)
|
||
case .uploaded:
|
||
return UIColor(hex: 0x16A34A).withAlphaComponent(0.12)
|
||
case .failed:
|
||
return AppColor.danger.withAlphaComponent(0.12)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 有线传输页左侧时间快速导航。
|
||
private final class WiredTransferTimeSidebarView: UIView {
|
||
var onToggleSidebar: (() -> Void)?
|
||
var onTimeSlotSelected: ((String) -> Void)?
|
||
|
||
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)
|
||
setupUI()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) {
|
||
selectedRowView = nil
|
||
stackView.arrangedSubviews.forEach { view in
|
||
stackView.removeArrangedSubview(view)
|
||
view.removeFromSuperview()
|
||
}
|
||
|
||
for group in groups {
|
||
let dateContainer = UIView()
|
||
let dateLabel = UILabel()
|
||
dateLabel.text = group.dateLabel
|
||
dateLabel.font = .systemFont(ofSize: 12)
|
||
dateLabel.textColor = AppColor.textTertiary
|
||
dateContainer.addSubview(dateLabel)
|
||
stackView.addArrangedSubview(dateContainer)
|
||
dateContainer.snp.makeConstraints { make in
|
||
make.height.equalTo(24)
|
||
}
|
||
dateLabel.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(10)
|
||
make.trailing.equalToSuperview()
|
||
make.bottom.equalToSuperview().offset(-4)
|
||
}
|
||
|
||
for slot in group.slots {
|
||
let row = TimeSlotRowView()
|
||
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
|
||
make.height.equalTo(52)
|
||
}
|
||
}
|
||
}
|
||
scrollSelectedRowIntoVisible()
|
||
}
|
||
|
||
private func setupUI() {
|
||
backgroundColor = UIColor(hex: 0xF5F6F8)
|
||
headerButton.setTitle("收起", for: .normal)
|
||
headerButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||
headerButton.tintColor = AppColor.textSecondary
|
||
headerButton.setTitleColor(AppColor.textSecondary, for: .normal)
|
||
headerButton.titleLabel?.font = .systemFont(ofSize: 11)
|
||
headerButton.semanticContentAttribute = .forceLeftToRight
|
||
headerButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
stackView.axis = .vertical
|
||
|
||
addSubview(headerButton)
|
||
addSubview(scrollView)
|
||
scrollView.addSubview(stackView)
|
||
|
||
headerButton.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(36)
|
||
}
|
||
scrollView.snp.makeConstraints { make in
|
||
make.top.equalTo(headerButton.snp.bottom)
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
stackView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
make.width.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@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 {
|
||
private let timeLabel = UILabel()
|
||
private let countLabel = UILabel()
|
||
private let arrowView = UIImageView(image: UIImage(systemName: "play.fill"))
|
||
|
||
var onTap: (() -> Void)?
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUI()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(slot: TravelAlbumOTGTimeSlot, selected: Bool) {
|
||
backgroundColor = selected ? .white : .clear
|
||
timeLabel.text = slot.slotStartLabel
|
||
timeLabel.font = .systemFont(ofSize: 13, weight: selected ? .semibold : .regular)
|
||
timeLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
|
||
arrowView.tintColor = selected ? AppColor.primary : AppColor.textPrimary
|
||
countLabel.text = "\(slot.photoCount)张"
|
||
}
|
||
|
||
private func setupUI() {
|
||
arrowView.contentMode = .scaleAspectFit
|
||
timeLabel.textAlignment = .center
|
||
countLabel.font = .systemFont(ofSize: 10)
|
||
countLabel.textColor = AppColor.textTertiary
|
||
countLabel.textAlignment = .center
|
||
addTarget(self, action: #selector(rowTapped), for: .touchUpInside)
|
||
|
||
addSubview(arrowView)
|
||
addSubview(timeLabel)
|
||
addSubview(countLabel)
|
||
|
||
arrowView.snp.makeConstraints { make in
|
||
make.centerY.equalTo(timeLabel)
|
||
make.trailing.equalTo(timeLabel.snp.leading).offset(-2)
|
||
make.size.equalTo(10)
|
||
}
|
||
timeLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(8)
|
||
make.centerX.equalToSuperview().offset(6)
|
||
}
|
||
countLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(timeLabel.snp.bottom).offset(2)
|
||
make.centerX.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@objc private func rowTapped() {
|
||
onTap?()
|
||
}
|
||
}
|
||
|
||
private extension Array {
|
||
subscript(safe index: Index) -> Element? {
|
||
indices.contains(index) ? self[index] : nil
|
||
}
|
||
}
|