Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift
T
lujiuyin 396597a160 feat: 优化自动修图设置与模板网格交互
同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。
2026-08-27 17:15:50 +08:00

1667 lines
69 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 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 = WiredTransferSettingChipButton(showsChevron: true)
private let formatButton = WiredTransferSettingChipButton()
private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
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)?
private var previewLoadingTask: Task<Void, Never>?
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
var taskConfiguration = UIButton.Configuration.plain()
taskConfiguration.title = "修图任务"
taskConfiguration.image = UIImage(systemName: "list.bullet")?.withTintColor(.white, renderingMode: .alwaysOriginal)
taskConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
taskConfiguration.imagePadding = 4
taskConfiguration.baseForegroundColor = .white
taskConfiguration.imageColorTransformer = UIConfigurationColorTransformer { _ in .white }
taskConfiguration.background.backgroundColor = .clear
taskConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 4)
taskConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 13, weight: .medium)
outgoing.foregroundColor = .white
return outgoing
}
let taskButton = UIButton(type: .custom)
taskButton.configuration = taskConfiguration
taskButton.tintColor = .white
taskButton.configurationUpdateHandler = { button in
button.alpha = button.isHighlighted ? 0.6 : 1
}
taskButton.accessibilityLabel = "查看AI修图任务"
taskButton.accessibilityIdentifier = "wiredTransfer.aiRetouchTasksButton"
taskButton.addTarget(self, action: #selector(openAIJobList), for: .touchUpInside)
taskButton.snp.makeConstraints { make in
make.height.equalTo(44)
make.width.greaterThanOrEqualTo(44)
}
let taskItem = UIBarButtonItem(customView: taskButton)
taskItem.tintColor = .white
taskItem.accessibilityLabel = "查看AI修图任务"
if #available(iOS 26.0, *) {
// 蓝色导航栏使用轻量入口,避免系统共享玻璃背景形成深色胶囊。
taskItem.hidesSharedBackground = true
}
navigationItem.rightBarButtonItem = taskItem
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationEnteredBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationBecameActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
deinit { NotificationCenter.default.removeObserver(self) }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyNavigationBarStyle()
viewModel.start()
navigationController?.setNavigationBarHidden(false, animated: animated)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
previewLoadingTask?.cancel()
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 {
chipsStack.addArrangedSubview($0)
}
formatButton.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.accessibilityIdentifier = "wiredTransfer.photoCollectionView"
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)
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)
}
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)
retouchButton.addTarget(self, action: #selector(retouchTapped), 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.apply(title: viewModel.isUpdatingAutoRetouchConfiguration ? "保存中" : viewModel.retouchOption)
retouchButton.isEnabled = !viewModel.isUpdatingAutoRetouchConfiguration
formatButton.apply(title: "JPG")
modeButton.apply(title: viewModel.transferModeOption)
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.onRetryRetouch = { [weak self] in self?.viewModel.retryAutoRetouch(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 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 openAIJobList() {
guard let navigationController,
navigationController.topViewController === self,
presentedViewController == nil,
previewLoadingTask == nil else { return }
navigationController.pushViewController(
TravelAlbumAIJobListViewController(api: viewModel.autoRetouchAPI),
animated: true
)
}
@objc private func applicationEnteredBackground() { viewModel.applicationDidEnterBackground() }
@objc private func applicationBecameActive() { viewModel.applicationDidBecomeActive() }
@objc private func retouchTapped() {
guard presentedViewController == nil else { return }
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: AppStore.shared.session.currentScenicId,
configuration: viewModel.autoRetouchConfiguration,
allowsModeSelection: true
)
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
viewModel: settingViewModel,
api: viewModel.autoRetouchAPI
)
controller.onConfirm = { [weak self] configuration in
guard let self else { return }
Task { await self.viewModel.updateAutoRetouchConfiguration(configuration) }
}
present(controller, animated: true)
}
@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 = WiredCameraTransferHelpDocument.bundledURL() else {
showToast("帮助文档暂不可用")
return
}
navigationController?.pushViewController(
AgreementWebViewController(title: WiredCameraTransferHelpDocument.pageTitle, url: url),
animated: true
)
}
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
guard previewLoadingTask == nil, presentedViewController == nil else { return }
if item.autoRetouchState == .completed {
presentAutoRetouchPreview(photoId: item.id)
return
}
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)
}
private func presentAutoRetouchPreview(photoId: String) {
previewLoadingTask = Task { @MainActor [weak self] in
guard let self else { return }
showLoading()
defer {
hideLoading()
previewLoadingTask = nil
}
do {
let project = try await viewModel.loadAutoRetouchPreviewProject(photoId: photoId)
try Task.checkCancellation()
guard viewIfLoaded?.window != nil,
presentedViewController == nil,
!viewModel.selectUploadMode else { return }
present(
TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
startKind: .retouched,
allowsActions: false
),
animated: true
)
} catch is CancellationError {
// 离开传输页后不再弹出预览或错误提示。
} catch {
guard !Task.isCancelled else { return }
showToast(error.localizedDescription.isEmpty ? "修图结果加载失败,请重试" : error.localizedDescription)
}
}
}
}
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。
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 retouchBadgeLabel = UILabel()
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 onRetryRetouch: (() -> 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
onRetryRetouch = 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)
applyRetouchBadge(state: item.autoRetouchState)
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(
canRetryUpload: item.status == .failed || item.status == .pending,
canRetryRetouch: item.autoRetouchState == .failed
)
accessibilityLabel = "\(item.fileName),\(statusText(item.status))"
if !retouchBadgeLabel.isHidden, let retouchStatus = retouchBadgeLabel.accessibilityLabel {
accessibilityLabel?.append(",修图状态:\(retouchStatus)")
}
if let error = item.autoRetouchErrorMessage, !error.isEmpty {
accessibilityHint = "失败原因:\(error)"
} else {
accessibilityHint = nil
}
updateImageConstraints(selectionMode: selectionMode)
}
private func applyRetouchBadge(state: TravelAlbumAutoRetouchState) {
retouchBadgeLabel.isHidden = state == .none
switch state {
case .none:
retouchBadgeLabel.backgroundColor = .clear
retouchBadgeLabel.accessibilityLabel = nil
case .pendingSubmission, .submitting, .processing:
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x7C3AED)
retouchBadgeLabel.accessibilityLabel = "修图中"
case .completed:
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x047857)
retouchBadgeLabel.accessibilityLabel = "修图成功"
case .failed:
retouchBadgeLabel.backgroundColor = AppColor.danger
retouchBadgeLabel.accessibilityLabel = "修图失败"
}
}
private func setupUI() {
contentView.backgroundColor = .white
selectionIconView.contentMode = .scaleAspectFit
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 6
imageView.accessibilityIdentifier = "wiredTransfer.thumbnail"
retouchBadgeLabel.text = "修"
retouchBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
retouchBadgeLabel.textColor = .white
retouchBadgeLabel.textAlignment = .center
retouchBadgeLabel.backgroundColor = .clear
retouchBadgeLabel.layer.cornerRadius = 8
retouchBadgeLabel.clipsToBounds = true
retouchBadgeLabel.isHidden = true
retouchBadgeLabel.accessibilityIdentifier = "wiredTransfer.retouchBadge"
statusLabel.accessibilityIdentifier = "wiredTransfer.uploadStatus"
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
progressView.accessibilityIdentifier = "wiredTransfer.uploadProgress"
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(retouchBadgeLabel)
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)
retouchBadgeLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(2)
make.trailing.equalTo(imageView).offset(-2)
make.size.equalTo(16)
}
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(canRetryUpload: Bool, canRetryRetouch: Bool) -> UIMenu {
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
self?.onRetry?()
}
retry.attributes = canRetryUpload ? [] : [.disabled]
let retryRetouch = UIAction(title: "重新修图", image: UIImage(systemName: "wand.and.stars")) { [weak self] _ in
self?.onRetryRetouch?()
}
retryRetouch.attributes = canRetryRetouch ? [] : [.disabled]
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.onDelete?()
}
return UIMenu(children: [retry, retryRetouch, 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
}
}