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

1477 lines
60 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()
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)?
init(viewModel: WiredCameraTransferViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
navigationItem.largeTitleDisplayMode = .never
configureBlueNavigationBarAppearance()
let titleStack = UIStackView(arrangedSubviews: [titleLabel, phoneLabel])
titleStack.axis = .vertical
titleStack.alignment = .center
titleStack.spacing = 1
navigationItem.titleView = titleStack
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyNavigationBarStyle()
viewModel.start()
navigationController?.setNavigationBarHidden(false, animated: animated)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.stop()
restoreNavigationBarStyle()
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
titleLabel.text = viewModel.albumTitle
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = .white
titleLabel.textAlignment = .center
phoneLabel.text = viewModel.headerPhone
phoneLabel.font = .systemFont(ofSize: 11)
phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92)
phoneLabel.textAlignment = .center
statusCard.backgroundColor = .white
statusCard.layer.cornerRadius = 12
statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor
statusCard.layer.shadowOpacity = 1
statusCard.layer.shadowRadius = 8
statusCard.layer.shadowOffset = CGSize(width: 0, height: 3)
storagePanel.layer.cornerRadius = 12
storagePanel.layer.borderColor = AppColor.border.cgColor
storagePanel.layer.borderWidth = 3
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
storageTitleLabel.textColor = AppColor.textPrimary
storageTitleLabel.textAlignment = .center
storageTitleLabel.adjustsFontSizeToFitWidth = true
storageTitleLabel.minimumScaleFactor = 0.72
storageValueLabel.font = .systemFont(ofSize: 10)
storageValueLabel.textColor = AppColor.primary
storageValueLabel.textAlignment = .center
storageValueLabel.adjustsFontSizeToFitWidth = true
storageValueLabel.minimumScaleFactor = 0.72
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
refreshButton.setTitleColor(.white, for: .normal)
refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
refreshButton.backgroundColor = AppColor.primary
refreshButton.layer.cornerRadius = 8
helpLabel.font = .systemFont(ofSize: 10)
helpLabel.numberOfLines = 3
helpLabel.isUserInteractionEnabled = true
chipsStack.axis = .horizontal
chipsStack.spacing = 6
chipsStack.distribution = .fillEqually
[retouchButton, formatButton, modeButton].forEach {
chipsStack.addArrangedSubview($0)
}
retouchButton.isUserInteractionEnabled = false
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.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)
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.retouchOption)
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.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 batchTapped() {
viewModel.onBatchUploadButtonClick()
}
@objc private func historyImportTapped() {
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
viewModel.pauseLiveTransferForHistoricalImport()
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
importController.onImportFinished = { [weak self] importedCount, failedCount, importedPhotoIds in
guard let self else { return }
self.viewModel.handleNewlyImportedPhotoIds(importedPhotoIds)
self.viewModel.start()
if failedCount > 0 {
self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败")
} else {
self.showToast("已导入 \(importedCount) 张照片")
}
}
importController.onImportCancelled = { [weak self] in
self?.viewModel.start()
}
let navigationController = UINavigationController(rootViewController: importController)
navigationController.modalPresentationStyle = .pageSheet
present(navigationController, animated: true)
}
@objc private func albumImportTapped() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 0
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
@objc private func specifyTapped() {
guard viewModel.canOpenSpecifyUploadChooser else { return }
let controller = WiredSpecifyUploadViewController(options: viewModel.specifyUploadOptions)
controller.onConfirm = { [weak self] option in
self?.viewModel.onSpecifyUploadOptionSelected(option)
}
controller.modalPresentationStyle = .pageSheet
if let sheet = controller.sheetPresentationController {
sheet.detents = [
.custom(identifier: .init("wiredSpecifyUpload")) { _ in 260 }
]
sheet.prefersGrabberVisible = false
sheet.preferredCornerRadius = 16
}
present(controller, animated: true)
}
@objc private func selectAllTapped() {
viewModel.toggleVisibleSelectablePhotos()
}
@objc private func toggleSidebarTapped() {
viewModel.toggleSidebar()
}
@objc private func tabTapped(_ sender: UIButton) {
guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return }
viewModel.selectTab(tab)
}
@objc private func helpTapped() {
guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return }
UIApplication.shared.open(url)
}
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
guard let url = item.thumbnailURL else {
showToast("暂无可预览图片")
return
}
let previewItem: MediaPreviewItem = url.isFileURL
? .localImage(url: url)
: MediaPreviewItem(source: .remoteImage(url))
MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
}
}
extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
Task { @MainActor in
showLoading()
let items = await loadPhoneAlbumImportItems(from: results)
let result = viewModel.importPhoneAlbumImages(items)
hideLoading()
if result.failedCount > 0 {
showToast("已导入 \(result.importedCount) 张,\(result.failedCount) 张失败")
} else if result.importedCount > 0 {
showToast("已导入 \(result.importedCount) 张照片")
} else {
showToast("未能导入照片")
}
}
}
private func loadPhoneAlbumImportItems(from results: [PHPickerResult]) async -> [TravelAlbumPhoneAlbumImportItem] {
var items: [TravelAlbumPhoneAlbumImportItem] = []
for (index, result) in results.enumerated() {
if let item = await Self.loadPhoneAlbumImportItem(from: result, index: index) {
items.append(item)
}
}
return items
}
private static func loadPhoneAlbumImportItem(
from result: PHPickerResult,
index: Int
) async -> TravelAlbumPhoneAlbumImportItem? {
let provider = result.itemProvider
let typeIdentifier = preferredImageTypeIdentifier(from: provider)
let fileName = importFileName(
suggestedName: provider.suggestedName,
typeIdentifier: typeIdentifier,
index: index
)
return await withCheckedContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in
guard let data, !data.isEmpty else {
continuation.resume(returning: nil)
return
}
continuation.resume(
returning: TravelAlbumPhoneAlbumImportItem(
data: data,
fileName: fileName,
createdAt: Date()
)
)
}
}
}
private static func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String {
provider.registeredTypeIdentifiers.first { identifier in
UTType(identifier)?.conforms(to: .image) == true
} ?? UTType.image.identifier
}
private static func importFileName(suggestedName: String?, typeIdentifier: String, index: Int) -> String {
let fallback = "album_image_\(Int(Date().timeIntervalSince1970))_\(index)"
let rawName = suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines)
let baseName = rawName?.isEmpty == false ? rawName! : fallback
guard URL(fileURLWithPath: baseName).pathExtension.isEmpty else {
return baseName
}
let ext = UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg"
return "\(baseName).\(ext)"
}
}
/// OTG
private final class WiredSpecifyUploadViewController: UIViewController {
var onConfirm: ((TravelAlbumOTGSpecifyUploadOption) -> Void)?
private let options: [TravelAlbumOTGSpecifyUploadOption]
private var selectedOption: TravelAlbumOTGSpecifyUploadOption
private var optionRows: [WiredSpecifyUploadOptionRow] = []
private let headerView = UIView()
private let cancelTitleButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let optionsStack = UIStackView()
private let bottomButtonsStack = UIStackView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
init(options: [TravelAlbumOTGSpecifyUploadOption]) {
let resolvedOptions = options.isEmpty ? TravelAlbumOTGSpecifyUploadOption.allCases : options
self.options = resolvedOptions
selectedOption = resolvedOptions[0]
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
}
private func setupUI() {
view.backgroundColor = AppColor.pageBackground
cancelTitleButton.setTitle("取消", for: .normal)
cancelTitleButton.setTitleColor(AppColor.primary, for: .normal)
cancelTitleButton.titleLabel?.font = .systemFont(ofSize: 16)
cancelTitleButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
titleLabel.text = "指定上传"
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
optionsStack.axis = .vertical
optionsStack.spacing = 12
for option in options {
let row = WiredSpecifyUploadOptionRow(option: option)
row.apply(selected: option == selectedOption)
row.onTap = { [weak self] option in self?.selectOption(option) }
optionsStack.addArrangedSubview(row)
row.snp.makeConstraints { make in
make.height.equalTo(52)
}
optionRows.append(row)
}
bottomButtonsStack.axis = .horizontal
bottomButtonsStack.spacing = 12
bottomButtonsStack.distribution = .fillEqually
configureActionButton(cancelButton, title: "取消", filled: false)
configureActionButton(confirmButton, title: "确认上传", filled: true)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
view.addSubview(headerView)
headerView.addSubview(cancelTitleButton)
headerView.addSubview(titleLabel)
view.addSubview(optionsStack)
view.addSubview(bottomButtonsStack)
bottomButtonsStack.addArrangedSubview(cancelButton)
bottomButtonsStack.addArrangedSubview(confirmButton)
}
private func setupConstraints() {
headerView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(52)
}
cancelTitleButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.height.equalTo(40)
}
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.greaterThanOrEqualTo(cancelTitleButton.snp.trailing).offset(12)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom)
make.leading.trailing.equalToSuperview().inset(16)
}
bottomButtonsStack.snp.makeConstraints { make in
make.top.equalTo(optionsStack.snp.bottom).offset(20)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
private func configureActionButton(_ button: UIButton, title: String, filled: Bool) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.layer.cornerRadius = 10
if filled {
button.backgroundColor = AppColor.primary
button.setTitleColor(.white, for: .normal)
} else {
button.backgroundColor = .white
button.setTitleColor(AppColor.primary, for: .normal)
button.layer.borderWidth = 1
button.layer.borderColor = AppColor.primary.cgColor
}
}
private func selectOption(_ option: TravelAlbumOTGSpecifyUploadOption) {
selectedOption = option
optionRows.forEach { row in
row.apply(selected: row.option == option)
}
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
@objc private func confirmTapped() {
let option = selectedOption
dismiss(animated: true) { [onConfirm] in
onConfirm?(option)
}
}
}
/// OTG
private final class WiredSpecifyUploadOptionRow: UIControl {
let option: TravelAlbumOTGSpecifyUploadOption
var onTap: ((TravelAlbumOTGSpecifyUploadOption) -> Void)?
private let titleLabel = UILabel()
private let radioView = UIImageView()
init(option: TravelAlbumOTGSpecifyUploadOption) {
self.option = option
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(selected: Bool) {
radioView.image = UIImage(systemName: selected ? "largecircle.fill.circle" : "circle")
radioView.tintColor = selected ? AppColor.textPrimary : AppColor.border
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = 10
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
titleLabel.text = option.title
titleLabel.font = .systemFont(ofSize: 15)
titleLabel.textColor = AppColor.textPrimary
radioView.contentMode = .scaleAspectFit
addTarget(self, action: #selector(rowTapped), for: .touchUpInside)
addSubview(titleLabel)
addSubview(radioView)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(radioView.snp.leading).offset(-10)
}
radioView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(24)
}
}
@objc private func rowTapped() {
onTap?(option)
}
}
extension WiredCameraTransferViewController: UICollectionViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView === collectionView else { return }
syncSidebarSelectionWithCurrentScrollPosition()
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
if viewModel.selectUploadMode {
viewModel.toggleTransferPhotoSelection(photoId: item.id)
} else {
presentPhotoPreview(item)
}
}
}
/// 线 Header
private final class WiredTransferSectionHeaderView: UICollectionReusableView {
static let reuseIdentifier = "WiredTransferSectionHeaderView"
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.pageBackground
titleLabel.font = .systemFont(ofSize: 11)
titleLabel.textColor = AppColor.textSecondary
addSubview(titleLabel)
titleLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String) {
titleLabel.text = title
}
}
/// 线 Cell
private final class WiredTransferPhotoCell: UICollectionViewCell {
static let reuseIdentifier = "WiredTransferPhotoCell"
private static let previewImageSize = CGSize(width: 96, height: 96)
private let selectionIconView = UIImageView()
private let imageView = UIImageView()
private let statusLabel = UILabel()
private let titleLabel = UILabel()
private let sizeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let menuButton = UIButton(type: .system)
private let separatorView = UIView()
var onRetry: (() -> Void)?
var onDelete: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
onRetry = nil
onDelete = nil
menuButton.menu = nil
}
func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) {
let canSelect = item.canSelectForUpload
let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1
if let url = item.thumbnailURL {
let processor = DownsamplingImageProcessor(size: Self.previewImageSize)
imageView.kf.setImage(
with: url,
placeholder: UIImage(systemName: "photo"),
options: [
.processor(processor),
.scaleFactor(UIScreen.main.scale),
.backgroundDecode
]
)
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.textTertiary
}
imageView.alpha = rowAlpha
titleLabel.text = item.fileName
titleLabel.alpha = rowAlpha
sizeLabel.text = item.fileSizeText
sizeLabel.alpha = rowAlpha
statusLabel.text = statusText(item.status)
statusLabel.textColor = statusTextColor(item.status)
statusLabel.backgroundColor = statusBackgroundColor(item.status)
progressView.isHidden = item.status != .uploading && item.status != .transferring
progressView.progress = Float(item.progress) / 100.0
selectionIconView.isHidden = !selectionMode
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
menuButton.isHidden = selectionMode
menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending)
updateImageConstraints(selectionMode: selectionMode)
}
private func setupUI() {
contentView.backgroundColor = .white
selectionIconView.contentMode = .scaleAspectFit
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 6
statusLabel.font = .systemFont(ofSize: 9)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 3
statusLabel.clipsToBounds = true
statusLabel.setContentHuggingPriority(.required, for: .horizontal)
statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.lineBreakMode = .byTruncatingMiddle
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary
progressView.progressTintColor = AppColor.primary
progressView.trackTintColor = AppColor.border
menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal)
menuButton.tintColor = AppColor.textSecondary
menuButton.showsMenuAsPrimaryAction = true
separatorView.backgroundColor = AppColor.border
contentView.addSubview(selectionIconView)
contentView.addSubview(imageView)
contentView.addSubview(titleLabel)
contentView.addSubview(statusLabel)
contentView.addSubview(sizeLabel)
contentView.addSubview(progressView)
contentView.addSubview(menuButton)
contentView.addSubview(separatorView)
selectionIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
updateImageConstraints(selectionMode: true)
titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(1)
make.leading.equalTo(imageView.snp.trailing).offset(8)
make.trailing.equalTo(statusLabel.snp.leading).offset(-4)
}
statusLabel.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalTo(menuButton.snp.leading).offset(-4)
make.height.equalTo(17)
make.width.greaterThanOrEqualTo(44)
}
sizeLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(5)
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-10)
}
progressView.snp.makeConstraints { make in
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-10)
make.top.equalTo(sizeLabel.snp.bottom).offset(7)
make.height.equalTo(2)
}
menuButton.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-4)
make.size.equalTo(32)
}
separatorView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(10)
make.bottom.equalToSuperview()
make.height.equalTo(1 / UIScreen.main.scale)
}
}
private func makeMenu(canRetry: Bool) -> UIMenu {
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
self?.onRetry?()
}
retry.attributes = canRetry ? [] : [.disabled]
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.onDelete?()
}
return UIMenu(children: [retry, delete])
}
private func updateImageConstraints(selectionMode: Bool) {
imageView.snp.remakeConstraints { make in
if selectionMode {
make.leading.equalTo(selectionIconView.snp.trailing).offset(8)
} else {
make.leading.equalToSuperview().offset(10)
}
make.top.equalToSuperview().offset(8)
make.size.equalTo(48)
make.bottom.lessThanOrEqualToSuperview().offset(-8)
}
}
private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String {
switch status {
case .pending: return "待上传"
case .transferring: return "传输中"
case .uploading: return "上传中"
case .uploaded: return "已上传"
case .failed: return "上传失败"
}
}
private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
switch status {
case .pending: return AppColor.textSecondary
case .transferring, .uploading: return AppColor.primary
case .uploaded: return UIColor(hex: 0x16A34A)
case .failed: return AppColor.danger
}
}
private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
switch status {
case .pending:
return AppColor.pageBackground
case .transferring, .uploading:
return AppColor.primary.withAlphaComponent(0.12)
case .uploaded:
return UIColor(hex: 0x16A34A).withAlphaComponent(0.12)
case .failed:
return AppColor.danger.withAlphaComponent(0.12)
}
}
}
/// 线
private final class WiredTransferTimeSidebarView: UIView {
var onToggleSidebar: (() -> Void)?
var onTimeSlotSelected: ((String) -> Void)?
private let headerButton = UIButton(type: .system)
private let scrollView = UIScrollView()
private let stackView = UIStackView()
private weak var selectedRowView: TimeSlotRowView?
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) {
selectedRowView = nil
stackView.arrangedSubviews.forEach { view in
stackView.removeArrangedSubview(view)
view.removeFromSuperview()
}
for group in groups {
let dateContainer = UIView()
let dateLabel = UILabel()
dateLabel.text = group.dateLabel
dateLabel.font = .systemFont(ofSize: 12)
dateLabel.textColor = AppColor.textTertiary
dateContainer.addSubview(dateLabel)
stackView.addArrangedSubview(dateContainer)
dateContainer.snp.makeConstraints { make in
make.height.equalTo(24)
}
dateLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.trailing.equalToSuperview()
make.bottom.equalToSuperview().offset(-4)
}
for slot in group.slots {
let row = TimeSlotRowView()
let selected = slot.id == selectedSlotId
row.apply(slot: slot, selected: selected)
if selected {
selectedRowView = row
}
row.onTap = { [weak self] in self?.onTimeSlotSelected?(slot.id) }
stackView.addArrangedSubview(row)
row.snp.makeConstraints { make in
make.height.equalTo(52)
}
}
}
scrollSelectedRowIntoVisible()
}
private func setupUI() {
backgroundColor = UIColor(hex: 0xF5F6F8)
headerButton.setTitle("收起", for: .normal)
headerButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
headerButton.tintColor = AppColor.textSecondary
headerButton.setTitleColor(AppColor.textSecondary, for: .normal)
headerButton.titleLabel?.font = .systemFont(ofSize: 11)
headerButton.semanticContentAttribute = .forceLeftToRight
headerButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
scrollView.showsVerticalScrollIndicator = false
stackView.axis = .vertical
addSubview(headerButton)
addSubview(scrollView)
scrollView.addSubview(stackView)
headerButton.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(36)
}
scrollView.snp.makeConstraints { make in
make.top.equalTo(headerButton.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalToSuperview()
}
}
@objc private func toggleTapped() {
onToggleSidebar?()
}
private func scrollSelectedRowIntoVisible() {
guard let selectedRowView else { return }
layoutIfNeeded()
let rowFrame = selectedRowView.convert(selectedRowView.bounds, to: stackView)
scrollView.scrollRectToVisible(rowFrame.insetBy(dx: 0, dy: -8), animated: false)
}
}
private final class TimeSlotRowView: UIControl {
private let timeLabel = UILabel()
private let countLabel = UILabel()
private let arrowView = UIImageView(image: UIImage(systemName: "play.fill"))
var onTap: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(slot: TravelAlbumOTGTimeSlot, selected: Bool) {
backgroundColor = selected ? .white : .clear
timeLabel.text = slot.slotStartLabel
timeLabel.font = .systemFont(ofSize: 13, weight: selected ? .semibold : .regular)
timeLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
arrowView.tintColor = selected ? AppColor.primary : AppColor.textPrimary
countLabel.text = "\(slot.photoCount)"
}
private func setupUI() {
arrowView.contentMode = .scaleAspectFit
timeLabel.textAlignment = .center
countLabel.font = .systemFont(ofSize: 10)
countLabel.textColor = AppColor.textTertiary
countLabel.textAlignment = .center
addTarget(self, action: #selector(rowTapped), for: .touchUpInside)
addSubview(arrowView)
addSubview(timeLabel)
addSubview(countLabel)
arrowView.snp.makeConstraints { make in
make.centerY.equalTo(timeLabel)
make.trailing.equalTo(timeLabel.snp.leading).offset(-2)
make.size.equalTo(10)
}
timeLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.centerX.equalToSuperview().offset(6)
}
countLabel.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(2)
make.centerX.equalToSuperview()
}
}
@objc private func rowTapped() {
onTap?()
}
}
private extension Array {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}