Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift

1056 lines
44 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WiredCameraTransferViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
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 headerView = UIView()
private let backButton = UIButton(type: .system)
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 statsCard = UIStackView()
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 batchButton = UIButton(type: .system)
private let historyImportButton = UIButton(type: .system)
private let specifyButton = UIButton(type: .system)
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() {
navigationController?.setNavigationBarHidden(true, animated: false)
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start()
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.stop()
navigationController?.setNavigationBarHidden(false, animated: animated)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
headerView.backgroundColor = AppColor.primary
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = .white
titleLabel.text = viewModel.albumTitle
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = .white
phoneLabel.text = viewModel.headerPhone
phoneLabel.font = .systemFont(ofSize: 13)
phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92)
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
storageValueLabel.font = .systemFont(ofSize: 10)
storageValueLabel.textColor = AppColor.primary
storageValueLabel.textAlignment = .center
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
[retouchButton, formatButton, modeButton].forEach {
configureChipButton($0)
chipsStack.addArrangedSubview($0)
}
statsCard.axis = .horizontal
statsCard.distribution = .fillEqually
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
configureBottomButton(batchButton, title: "批量上传", filled: true)
configureBottomButton(historyImportButton, title: "历史导入", filled: false)
configureBottomButton(specifyButton, title: "指定上传", filled: false)
view.addSubview(headerView)
headerView.addSubview(backButton)
headerView.addSubview(titleLabel)
headerView.addSubview(phoneLabel)
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(statsCard)
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(batchButton)
bottomBar.addSubview(historyImportButton)
bottomBar.addSubview(specifyButton)
}
override func setupConstraints() {
headerView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(118)
}
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(8)
make.bottom.equalToSuperview().offset(-28)
make.size.equalTo(44)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(backButton.snp.trailing).offset(4)
make.trailing.equalToSuperview().offset(-16)
make.top.equalTo(backButton).offset(4)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
statusCard.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom).offset(-10)
make.leading.trailing.equalToSuperview().inset(16)
}
storagePanel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(14)
make.width.greaterThanOrEqualTo(76)
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(10)
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(72)
}
helpLabel.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(4)
make.leading.equalTo(statusLabel)
make.trailing.equalTo(refreshButton)
}
chipsStack.snp.makeConstraints { make in
make.top.equalTo(storagePanel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(14)
make.height.equalTo(34)
}
statsCard.snp.makeConstraints { make in
make.top.equalTo(chipsStack.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(68)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
batchButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.equalToSuperview().offset(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()
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) }
}
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)
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)
}
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.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(viewModel.photoFormatOption.rawValue, 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)
}
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 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?.confirmDeletePhoto(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 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)
}
}
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.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.titleAlignment = .center
config.baseForegroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
config.background.backgroundColor = .white
let button = UIButton(configuration: config)
button.titleLabel?.numberOfLines = 2
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .semibold : .regular)
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
}
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)
}
@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 in
guard let self else { return }
self.viewModel.reloadLocalPhotos()
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 specifyTapped() {
viewModel.onSpecifyUploadButtonClick()
}
@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)
}
}
extension WiredCameraTransferViewController: UICollectionViewDelegate {
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)
}
}
}
/// 线 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
titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.lineBreakMode = .byTruncatingMiddle
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()
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?) {
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()
row.apply(slot: slot, selected: slot.id == selectedSlotId)
row.onTap = { [weak self] in self?.onTimeSlotSelected?(slot.id) }
stackView.addArrangedSubview(row)
row.snp.makeConstraints { make in
make.height.equalTo(52)
}
}
}
}
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 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
}
}