feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -0,0 +1,537 @@
//
// TravelAlbumCameraImportViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class TravelAlbumCameraImportViewController: UIViewController {
private enum Layout {
static let columns: CGFloat = 3
static let spacing: CGFloat = 2
static let inset: CGFloat = 2
static let headerHeight: CGFloat = 44
}
private let viewModel: TravelAlbumCameraImportViewModel
var onImportFinished: ((Int) -> Void)?
private var collectionView: UICollectionView!
private var sections: [TravelAlbumCameraImportSection] = []
private var lastCollectionWidth: CGFloat = 0
private lazy var importBarButton = UIBarButtonItem(
title: "导入 (0)",
style: .done,
target: self,
action: #selector(importTapped)
)
private lazy var helpBarButton = UIBarButtonItem(
image: UIImage(systemName: "questionmark.circle"),
style: .plain,
target: self,
action: #selector(helpTapped)
)
private let activityIndicator = UIActivityIndicatorView(style: .large)
private let messageLabel = UILabel()
private let helpButton = UIButton(type: .system)
///
init(viewModel: TravelAlbumCameraImportViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = "选择照片"
view.backgroundColor = .white
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "取消",
style: .plain,
target: self,
action: #selector(cancelTapped)
)
navigationItem.rightBarButtonItem = importBarButton
importBarButton.isEnabled = false
setupUI()
setupConstraints()
bindViewModel()
viewModel.loadPhotos()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let width = collectionView.bounds.width
guard width > 0, width != lastCollectionWidth else { return }
lastCollectionWidth = width
collectionView.collectionViewLayout.invalidateLayout()
}
private func setupUI() {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = Layout.spacing
layout.minimumInteritemSpacing = Layout.spacing
layout.sectionInset = UIEdgeInsets(
top: Layout.inset,
left: Layout.inset,
bottom: Layout.inset,
right: Layout.inset
)
layout.headerReferenceSize = CGSize(width: 0, height: Layout.headerHeight)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = .white
collectionView.allowsMultipleSelection = true
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(
TravelAlbumCameraImportPhotoCell.self,
forCellWithReuseIdentifier: TravelAlbumCameraImportPhotoCell.reuseIdentifier
)
collectionView.register(
TravelAlbumCameraImportSectionHeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier
)
activityIndicator.hidesWhenStopped = true
messageLabel.numberOfLines = 0
messageLabel.textAlignment = .center
messageLabel.textColor = AppColor.textSecondary
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.isHidden = true
helpButton.setTitle("如何切换到 MTP 模式", for: .normal)
helpButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
helpButton.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
helpButton.setTitleColor(AppColor.primary, for: .normal)
helpButton.layer.cornerRadius = 9
helpButton.isHidden = true
helpButton.addTarget(self, action: #selector(helpTapped), for: .touchUpInside)
view.addSubview(collectionView)
view.addSubview(activityIndicator)
view.addSubview(messageLabel)
view.addSubview(helpButton)
}
private func setupConstraints() {
collectionView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
activityIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
messageLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-28)
make.leading.greaterThanOrEqualToSuperview().offset(32)
make.trailing.lessThanOrEqualToSuperview().offset(-32)
}
helpButton.snp.makeConstraints { make in
make.top.equalTo(messageLabel.snp.bottom).offset(20)
make.centerX.equalToSuperview()
make.height.equalTo(38)
make.width.greaterThanOrEqualTo(160)
}
}
private func bindViewModel() {
viewModel.onStateUpdated = { [weak self] state in
self?.render(state)
}
viewModel.onSelectionUpdated = { [weak self] count in
self?.updateImportButton(count: count)
self?.collectionView.reloadData()
}
viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in
self?.updateHelpVisibility(show)
}
}
private func render(_ state: TravelAlbumCameraImportState) {
switch state {
case .idle, .loading:
collectionView.isHidden = true
messageLabel.isHidden = true
helpButton.isHidden = true
importBarButton.isEnabled = false
navigationItem.leftBarButtonItem?.isEnabled = true
activityIndicator.startAnimating()
case .loaded(let loadedSections):
activityIndicator.stopAnimating()
collectionView.isHidden = false
messageLabel.isHidden = true
helpButton.isHidden = true
navigationItem.leftBarButtonItem?.isEnabled = true
sections = loadedSections
collectionView.reloadData()
case .importing(let current, let total):
collectionView.isHidden = true
messageLabel.isHidden = false
messageLabel.text = "正在导入 \(current)/\(total)..."
helpButton.isHidden = true
importBarButton.isEnabled = false
navigationItem.leftBarButtonItem?.isEnabled = false
activityIndicator.startAnimating()
case .finished(let count):
activityIndicator.stopAnimating()
dismiss(animated: true) { [weak self] in
self?.onImportFinished?(count)
}
case .failed(let message):
activityIndicator.stopAnimating()
collectionView.isHidden = true
messageLabel.isHidden = false
messageLabel.text = message
importBarButton.isEnabled = false
navigationItem.leftBarButtonItem?.isEnabled = true
helpButton.isHidden = !viewModel.shouldShowSonyMTPHelp
}
}
private func updateHelpVisibility(_ show: Bool) {
navigationItem.rightBarButtonItems = show ? [importBarButton, helpBarButton] : [importBarButton]
if case .failed = viewModel.state {
helpButton.isHidden = !show
}
}
private func updateImportButton(count: Int) {
importBarButton.title = "导入 (\(count))"
importBarButton.isEnabled = count > 0
}
private func thumbnailPixelSize() -> Int {
guard let flow = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else {
return 300
}
return Int(itemSize(layout: flow).width * UIScreen.main.scale)
}
private func itemSize(layout: UICollectionViewFlowLayout) -> CGSize {
let totalSpacing = layout.sectionInset.left
+ layout.sectionInset.right
+ layout.minimumInteritemSpacing * (Layout.columns - 1)
let width = floor((collectionView.bounds.width - totalSpacing) / Layout.columns)
return CGSize(width: width, height: width + 28)
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
@objc private func importTapped() {
viewModel.importSelected()
}
@objc private func helpTapped() {
guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return }
UIApplication.shared.open(url)
}
}
extension TravelAlbumCameraImportViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
sections.count
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
sections[section].photos.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumCameraImportPhotoCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumCameraImportPhotoCell
let photo = sections[indexPath.section].photos[indexPath.item]
cell.apply(
photo: photo,
selected: viewModel.isPhotoSelected(id: photo.id),
alreadyImported: viewModel.isPhotoAlreadyInAlbum(id: photo.id),
maxPixelSize: thumbnailPixelSize()
) { [weak self] photo, maxPixelSize in
await self?.viewModel.thumbnailData(for: photo, maxPixelSize: maxPixelSize)
}
return cell
}
func collectionView(
_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath
) -> UICollectionReusableView {
guard kind == UICollectionView.elementKindSectionHeader else {
return UICollectionReusableView()
}
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier,
for: indexPath
) as! TravelAlbumCameraImportSectionHeaderView
let section = sections[indexPath.section]
header.apply(
title: section.title,
count: section.photos.count,
importableCount: viewModel.importablePhotoCount(in: section),
importedCount: viewModel.importedPhotoCount(in: section),
hasImportablePhotos: viewModel.hasImportablePhotos(in: section),
fullySelected: viewModel.isSectionFullySelected(section),
partiallySelected: viewModel.isSectionPartiallySelected(section)
)
header.onToggle = { [weak self] in
guard let self else { return }
self.viewModel.toggleSection(section)
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
return header
}
}
extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let photo = sections[indexPath.section].photos[indexPath.item]
guard !viewModel.isPhotoAlreadyInAlbum(id: photo.id) else { return }
viewModel.togglePhoto(id: photo.id)
collectionView.reloadItems(at: [indexPath])
collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
guard let flow = collectionViewLayout as? UICollectionViewFlowLayout else {
return CGSize(width: 120, height: 148)
}
return itemSize(layout: flow)
}
}
/// Cell
private final class TravelAlbumCameraImportPhotoCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumCameraImportPhotoCell"
private let imageView = UIImageView()
private let filenameLabel = UILabel()
private let checkmarkView = UIImageView()
private let selectedOverlayView = UIView()
private let importedOverlayView = UIView()
private let importedBadgeLabel = UILabel()
private var representedPhotoId: String?
private var loadToken = UUID()
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()
representedPhotoId = nil
loadToken = UUID()
imageView.image = nil
filenameLabel.text = nil
applySelection(false, alreadyImported: false)
applyImported(false)
}
///
func apply(
photo: CameraObject,
selected: Bool,
alreadyImported: Bool,
maxPixelSize: Int,
thumbnailProvider: @escaping (CameraObject, Int) async -> Data?
) {
representedPhotoId = photo.id
filenameLabel.text = photo.filename
setPlaceholderImage()
applyImported(alreadyImported)
applySelection(selected, alreadyImported: alreadyImported)
let token = UUID()
loadToken = token
Task { @MainActor in
guard let data = await thumbnailProvider(photo, maxPixelSize),
self.loadToken == token,
self.representedPhotoId == photo.id,
let image = UIImage(data: data) else {
return
}
self.imageView.image = image
self.imageView.tintColor = nil
}
}
private func setupUI() {
contentView.backgroundColor = AppColor.pageBackground
contentView.clipsToBounds = true
contentView.layer.cornerRadius = 4
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
selectedOverlayView.backgroundColor = AppColor.primary.withAlphaComponent(0.25)
selectedOverlayView.isHidden = true
importedOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
importedOverlayView.isHidden = true
importedBadgeLabel.text = "已在相册"
importedBadgeLabel.font = .systemFont(ofSize: 9, weight: .semibold)
importedBadgeLabel.textColor = .white
importedBadgeLabel.textAlignment = .center
importedBadgeLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55)
importedBadgeLabel.layer.cornerRadius = 4
importedBadgeLabel.clipsToBounds = true
importedBadgeLabel.isHidden = true
checkmarkView.image = UIImage(systemName: "checkmark.circle.fill")
checkmarkView.tintColor = AppColor.primary
checkmarkView.isHidden = true
filenameLabel.font = .systemFont(ofSize: 9, weight: .medium)
filenameLabel.textColor = AppColor.textSecondary
filenameLabel.textAlignment = .center
filenameLabel.numberOfLines = 2
[imageView, selectedOverlayView, importedOverlayView, importedBadgeLabel, checkmarkView, filenameLabel].forEach {
contentView.addSubview($0)
}
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(contentView.snp.width)
}
selectedOverlayView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(imageView)
}
importedOverlayView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(imageView)
}
importedBadgeLabel.snp.makeConstraints { make in
make.center.equalTo(imageView)
make.height.equalTo(18)
make.width.greaterThanOrEqualTo(52)
}
checkmarkView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(22)
}
filenameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview().inset(2)
make.bottom.lessThanOrEqualToSuperview().offset(-2)
}
}
private func setPlaceholderImage() {
let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .light)
imageView.image = UIImage(systemName: "photo", withConfiguration: config)
imageView.tintColor = AppColor.textTertiary
}
private func applySelection(_ selected: Bool, alreadyImported: Bool) {
let showSelection = selected && !alreadyImported
selectedOverlayView.isHidden = !showSelection
checkmarkView.isHidden = !showSelection
contentView.layer.borderWidth = showSelection ? 2 : 0
contentView.layer.borderColor = showSelection ? AppColor.primary.cgColor : nil
}
private func applyImported(_ alreadyImported: Bool) {
importedOverlayView.isHidden = !alreadyImported
importedBadgeLabel.isHidden = !alreadyImported
contentView.alpha = alreadyImported ? 0.55 : 1
}
}
/// Header
private final class TravelAlbumCameraImportSectionHeaderView: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumCameraImportSectionHeaderView"
var onToggle: (() -> Void)?
private let checkButton = UIButton(type: .system)
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(
title: String,
count: Int,
importableCount: Int,
importedCount: Int,
hasImportablePhotos: Bool,
fullySelected: Bool,
partiallySelected: Bool
) {
titleLabel.text = importedCount > 0 ? "\(title) · 可导入 \(importableCount) / 共 \(count)" : "\(title) · \(count)"
checkButton.isEnabled = hasImportablePhotos
let symbolName: String
if !hasImportablePhotos {
symbolName = "circle"
} else if fullySelected {
symbolName = "checkmark.circle.fill"
} else if partiallySelected {
symbolName = "minus.circle.fill"
} else {
symbolName = "circle"
}
checkButton.setImage(UIImage(systemName: symbolName), for: .normal)
checkButton.tintColor = hasImportablePhotos && (fullySelected || partiallySelected) ? AppColor.primary : AppColor.textTertiary
}
private func setupUI() {
backgroundColor = .white
checkButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
addSubview(checkButton)
addSubview(titleLabel)
checkButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(28)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(checkButton.snp.trailing).offset(8)
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
}
}
@objc private func toggleTapped() {
onToggle?()
}
}

View File

@ -166,7 +166,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) }
}
viewModel.onDeletedAlbum = { [weak self] _ in
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
Task { @MainActor in
guard let self else { return }
TravelAlbumOTGPhotoStore().clearAlbum(albumId: self.viewModel.albumId)
self.navigationController?.popViewController(animated: true)
}
}
}

View File

@ -3,10 +3,11 @@
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// 线 Android UI OTG
/// 线 Android OTG ImageCaptureCore
final class WiredCameraTransferViewController: BaseViewController {
private let viewModel: WiredCameraTransferViewModel
@ -22,10 +23,16 @@ final class WiredCameraTransferViewController: BaseViewController {
private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel()
private let chipsStack = UIStackView()
private let retouchButton = UIButton(type: .system)
private let formatButton = UIButton(type: .system)
private let modeButton = UIButton(type: .system)
private let statsCard = UIStackView()
private let emptyLabel = UILabel()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumOTGPhotoItem>!
private let bottomBar = UIView()
private let batchButton = UIButton(type: .system)
private let historyImportButton = UIButton(type: .system)
private let specifyButton = UIButton(type: .system)
init(viewModel: WiredCameraTransferViewModel) {
@ -42,8 +49,15 @@ final class WiredCameraTransferViewController: BaseViewController {
navigationController?.setNavigationBarHidden(true, animated: false)
}
override func viewDidLoad() {
super.viewDidLoad()
viewModel.start()
applyViewModel()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.stop()
navigationController?.setNavigationBarHidden(false, animated: animated)
}
@ -69,41 +83,50 @@ final class WiredCameraTransferViewController: BaseViewController {
storagePanel.layer.cornerRadius = 12
storagePanel.layer.borderColor = AppColor.border.cgColor
storagePanel.layer.borderWidth = 3
storageTitleLabel.text = viewModel.deviceModelName
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
storageTitleLabel.textColor = AppColor.textPrimary
storageTitleLabel.textAlignment = .center
storageValueLabel.text = viewModel.availableStorageText
storageValueLabel.font = .systemFont(ofSize: 10)
storageValueLabel.textColor = AppColor.primary
storageValueLabel.textAlignment = .center
statusLabel.text = viewModel.cameraStatusText
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
statusLabel.textColor = AppColor.danger
statusLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.10)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
refreshButton.setTitleColor(.white, for: .normal)
refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
refreshButton.backgroundColor = AppColor.danger
refreshButton.backgroundColor = AppColor.primary
refreshButton.layer.cornerRadius = 8
helpLabel.attributedText = helpText()
helpLabel.font = .systemFont(ofSize: 10)
helpLabel.numberOfLines = 2
helpLabel.numberOfLines = 3
helpLabel.isUserInteractionEnabled = true
chipsStack.axis = .horizontal
chipsStack.spacing = 6
[viewModel.retouchOption, viewModel.photoFormatOption, viewModel.transferModeOption].forEach {
chipsStack.addArrangedSubview(makeChip($0))
[retouchButton, formatButton, modeButton].forEach {
configureChipButton($0)
chipsStack.addArrangedSubview($0)
}
statsCard.axis = .horizontal
statsCard.distribution = .fillEqually
["全部", "已上传", "失败"].enumerated().forEach { index, title in
statsCard.addArrangedSubview(makeStat(title: title, count: viewModel.tabCounts[index], selected: index == 0, danger: index == 2))
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = AppColor.pageBackground
collectionView.delegate = self
collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumOTGPhotoItem>(collectionView: collectionView) {
[weak self] collectionView, indexPath, item in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier,
for: indexPath
) as! WiredTransferPhotoCell
let selected = self?.viewModel.selectedPhotoIds.contains(item.id) == true
cell.apply(item: item, selectionMode: self?.viewModel.selectUploadMode == true, selected: selected)
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) }
return cell
}
emptyLabel.text = "暂无照片"
@ -113,6 +136,7 @@ final class WiredCameraTransferViewController: BaseViewController {
bottomBar.backgroundColor = .white
configureBottomButton(batchButton, title: "批量上传", filled: true)
configureBottomButton(historyImportButton, title: "历史导入", filled: false)
configureBottomButton(specifyButton, title: "指定上传", filled: false)
view.addSubview(headerView)
@ -128,9 +152,11 @@ final class WiredCameraTransferViewController: BaseViewController {
statusCard.addSubview(helpLabel)
statusCard.addSubview(chipsStack)
statusCard.addSubview(statsCard)
view.addSubview(collectionView)
view.addSubview(emptyLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(batchButton)
bottomBar.addSubview(historyImportButton)
bottomBar.addSubview(specifyButton)
}
@ -206,11 +232,20 @@ final class WiredCameraTransferViewController: BaseViewController {
make.height.equalTo(44)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
historyImportButton.snp.makeConstraints { make in
make.top.height.width.equalTo(batchButton)
make.leading.equalTo(batchButton.snp.trailing).offset(8)
}
specifyButton.snp.makeConstraints { make in
make.top.height.width.equalTo(batchButton)
make.leading.equalTo(batchButton.snp.trailing).offset(12)
make.leading.equalTo(historyImportButton.snp.trailing).offset(8)
make.trailing.equalToSuperview().offset(-16)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(statusCard.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-10)
}
emptyLabel.snp.makeConstraints { make in
make.top.equalTo(statusCard.snp.bottom)
make.leading.trailing.equalToSuperview()
@ -219,62 +254,102 @@ final class WiredCameraTransferViewController: BaseViewController {
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onLiveShotSaved = { [weak self] filename in
Task { @MainActor in self?.showToast("已保存 \(filename)") }
}
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
specifyButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
specifyButton.addTarget(self, action: #selector(specifyTapped), for: .touchUpInside)
formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in
UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) }
})
formatButton.showsMenuAsPrimaryAction = true
modeButton.menu = UIMenu(children: ["边拍边传", "拍后传输"].map { option in
UIAction(title: option) { [weak self] _ in self?.viewModel.selectTransferMode(option) }
})
modeButton.showsMenuAsPrimaryAction = true
helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped)))
}
private func makeChip(_ text: String) -> UIView {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 11)
label.textColor = AppColor.textTertiary
label.backgroundColor = AppColor.pageBackground
label.layer.cornerRadius = 8
label.layer.borderWidth = 1
label.layer.borderColor = AppColor.border.cgColor
label.clipsToBounds = true
label.textAlignment = .center
return label
@MainActor
private func applyViewModel() {
storageTitleLabel.text = viewModel.deviceModelName
storageValueLabel.text = viewModel.availableStorageText
statusLabel.text = viewModel.cameraStatusText
let isFailed = viewModel.cameraStatusText.contains("失败") || viewModel.cameraStatusText.contains("未连接")
statusLabel.textColor = isFailed ? AppColor.danger : AppColor.primary
statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10)
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
retouchButton.setTitle(viewModel.retouchOption, for: .normal)
formatButton.setTitle(viewModel.photoFormatOption.rawValue, for: .normal)
modeButton.setTitle(viewModel.transferModeOption, for: .normal)
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
rebuildStats()
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumOTGPhotoItem>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.filteredPhotos())
dataSource.apply(snapshot, animatingDifferences: true)
emptyLabel.isHidden = !viewModel.filteredPhotos().isEmpty
collectionView.isHidden = viewModel.filteredPhotos().isEmpty
if viewModel.selectUploadMode {
let count = viewModel.selectedPhotoIds.count
batchButton.setTitle(count > 0 ? "上传选中(\(count))" : "取消", for: .normal)
} else {
batchButton.setTitle("批量上传", for: .normal)
}
historyImportButton.setTitle("历史导入", for: .normal)
}
private func makeStat(title: String, count: Int, selected: Bool, danger: Bool) -> UIView {
let container = UIView()
let countLabel = UILabel()
let titleLabel = UILabel()
let underline = UIView()
countLabel.text = "\(count)"
countLabel.font = .systemFont(ofSize: 18, weight: .semibold)
countLabel.textColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
countLabel.textAlignment = .center
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 13)
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
titleLabel.textAlignment = .center
underline.backgroundColor = selected ? AppColor.primary : .clear
underline.layer.cornerRadius = 1
container.addSubview(countLabel)
container.addSubview(titleLabel)
container.addSubview(underline)
countLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview()
private func rebuildStats() {
statsCard.arrangedSubviews.forEach { view in
statsCard.removeArrangedSubview(view)
view.removeFromSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(countLabel.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview()
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)
}
underline.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.centerX.equalToSuperview()
make.width.equalTo(24)
make.height.equalTo(2)
}
return container
}
private func configureChipButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 11)
button.setTitleColor(AppColor.textTertiary, for: .normal)
button.backgroundColor = AppColor.pageBackground
button.layer.cornerRadius = 8
button.layer.borderWidth = 1
button.layer.borderColor = AppColor.border.cgColor
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
}
private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton {
var config = UIButton.Configuration.plain()
config.title = "\(count)\n\(title)"
config.titleAlignment = .center
config.baseForegroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
config.background.backgroundColor = .white
let button = UIButton(configuration: config)
button.titleLabel?.numberOfLines = 2
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .semibold : .regular)
return button
}
private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) {
@ -292,20 +367,240 @@ final class WiredCameraTransferViewController: BaseViewController {
}
}
private func helpText() -> NSAttributedString {
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let spacing: CGFloat = 8
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 58))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 58))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 12
return section
}
}
private func helpText(_ sonyHint: String?) -> NSAttributedString {
let text = NSMutableAttributedString(
string: "未连接,查看",
string: sonyHint ?? "未连接,查看",
attributes: [.foregroundColor: AppColor.textSecondary]
)
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
if sonyHint == nil {
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
}
return text
}
private func confirmDeletePhoto(_ photoId: String) {
let alert = UIAlertController(title: "删除照片", message: "仅删除本地 OTG 缓存,不会删除服务端已上传素材。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
self?.viewModel.deletePhoto(photoId: photoId)
})
present(alert, animated: true)
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func uiOnlyTapped() {
viewModel.showUIOnlyMessage()
@objc private func refreshTapped() {
viewModel.refreshCameraFiles()
}
@objc private func batchTapped() {
viewModel.onBatchUploadButtonClick()
}
@objc private func historyImportTapped() {
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
importController.onImportFinished = { [weak self] count in
self?.viewModel.reloadLocalPhotos()
self?.showToast("已导入 \(count) 张照片")
}
let navigationController = UINavigationController(rootViewController: importController)
navigationController.modalPresentationStyle = .pageSheet
present(navigationController, animated: true)
}
@objc private func specifyTapped() {
viewModel.onSpecifyUploadButtonClick()
}
@objc private func tabTapped(_ sender: UIButton) {
guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return }
viewModel.selectTab(tab)
}
@objc private func helpTapped() {
guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return }
UIApplication.shared.open(url)
}
}
extension WiredCameraTransferViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
if viewModel.selectUploadMode {
viewModel.toggleTransferPhotoSelection(photoId: item.id)
} else if item.status == .failed {
viewModel.retryPhoto(photoId: item.id)
}
}
}
/// 线 Cell
private final class WiredTransferPhotoCell: UICollectionViewCell {
static let reuseIdentifier = "WiredTransferPhotoCell"
private let imageView = UIImageView()
private let statusLabel = UILabel()
private let titleLabel = UILabel()
private let sizeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let retryButton = UIButton(type: .system)
private let deleteButton = UIButton(type: .system)
private let selectionOverlay = UIImageView()
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
}
func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) {
if let url = item.thumbnailURL {
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.textTertiary
}
titleLabel.text = item.fileName
sizeLabel.text = item.fileSizeText
statusLabel.text = statusText(item.status, progress: item.progress)
statusLabel.backgroundColor = statusColor(item.status).withAlphaComponent(0.90)
progressView.isHidden = item.status != .uploading && item.status != .transferring
progressView.progress = Float(item.progress) / 100.0
retryButton.isHidden = item.status != .failed
deleteButton.isHidden = selectionMode
selectionOverlay.isHidden = !selectionMode
selectionOverlay.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionOverlay.tintColor = selected ? AppColor.primary : .white
}
private func setupUI() {
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 8
contentView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
statusLabel.font = .systemFont(ofSize: 10, weight: .medium)
statusLabel.textColor = .white
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
titleLabel.font = .systemFont(ofSize: 11, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary
retryButton.setImage(UIImage(systemName: "arrow.clockwise"), for: .normal)
retryButton.tintColor = AppColor.danger
deleteButton.setImage(UIImage(systemName: "trash"), for: .normal)
deleteButton.tintColor = AppColor.textTertiary
selectionOverlay.contentMode = .scaleAspectFit
contentView.addSubview(imageView)
imageView.addSubview(statusLabel)
imageView.addSubview(selectionOverlay)
contentView.addSubview(titleLabel)
contentView.addSubview(sizeLabel)
contentView.addSubview(progressView)
contentView.addSubview(retryButton)
contentView.addSubview(deleteButton)
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(contentView.snp.width)
}
statusLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(5)
make.height.equalTo(18)
make.width.greaterThanOrEqualTo(44)
}
selectionOverlay.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(6)
make.size.equalTo(22)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(5)
make.leading.equalToSuperview().offset(6)
make.trailing.equalTo(deleteButton.snp.leading).offset(-4)
}
sizeLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
progressView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview().inset(6)
}
retryButton.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-2)
make.size.equalTo(26)
}
deleteButton.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-2)
make.size.equalTo(26)
}
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
private func statusText(_ status: TravelAlbumOTGUploadStatus, progress: Int) -> String {
switch status {
case .pending: return "待上传"
case .transferring: return "传输\(progress)%"
case .uploading: return "上传\(progress)%"
case .uploaded: return "已上传"
case .failed: return "失败"
}
}
private func statusColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
switch status {
case .pending: return AppColor.textTertiary
case .transferring, .uploading: return AppColor.primary
case .uploaded: return UIColor(hex: 0x16A34A)
case .failed: return AppColor.danger
}
}
@objc private func retryTapped() {
onRetry?()
}
@objc private func deleteTapped() {
onDelete?()
}
}