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

622 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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

//
// WiredCameraTransferViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// 线 Android OTG ImageCaptureCore
final class WiredCameraTransferViewController: BaseViewController {
private let viewModel: WiredCameraTransferViewModel
private let headerView = UIView()
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let phoneLabel = UILabel()
private let statusCard = UIView()
private let storagePanel = UIView()
private let storageTitleLabel = UILabel()
private let storageValueLabel = UILabel()
private let statusLabel = UILabel()
private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel()
private let chipsStack = UIStackView()
private let retouchButton = UIButton(type: .system)
private let formatButton = UIButton(type: .system)
private let modeButton = UIButton(type: .system)
private let statsCard = UIStackView()
private let 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) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
navigationController?.setNavigationBarHidden(true, animated: false)
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start()
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.stop()
navigationController?.setNavigationBarHidden(false, animated: animated)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
headerView.backgroundColor = AppColor.primary
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = .white
titleLabel.text = viewModel.albumTitle
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = .white
phoneLabel.text = viewModel.headerPhone
phoneLabel.font = .systemFont(ofSize: 13)
phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92)
statusCard.backgroundColor = .white
statusCard.layer.cornerRadius = 12
statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor
statusCard.layer.shadowOpacity = 1
statusCard.layer.shadowRadius = 8
statusCard.layer.shadowOffset = CGSize(width: 0, height: 3)
storagePanel.layer.cornerRadius = 12
storagePanel.layer.borderColor = AppColor.border.cgColor
storagePanel.layer.borderWidth = 3
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
storageTitleLabel.textColor = AppColor.textPrimary
storageTitleLabel.textAlignment = .center
storageValueLabel.font = .systemFont(ofSize: 10)
storageValueLabel.textColor = AppColor.primary
storageValueLabel.textAlignment = .center
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
refreshButton.setTitleColor(.white, for: .normal)
refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
refreshButton.backgroundColor = AppColor.primary
refreshButton.layer.cornerRadius = 8
helpLabel.font = .systemFont(ofSize: 10)
helpLabel.numberOfLines = 3
helpLabel.isUserInteractionEnabled = true
chipsStack.axis = .horizontal
chipsStack.spacing = 6
[retouchButton, formatButton, modeButton].forEach {
configureChipButton($0)
chipsStack.addArrangedSubview($0)
}
statsCard.axis = .horizontal
statsCard.distribution = .fillEqually
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 = "暂无照片"
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center
bottomBar.backgroundColor = .white
configureBottomButton(batchButton, title: "批量上传", filled: true)
configureBottomButton(historyImportButton, title: "历史导入", filled: false)
configureBottomButton(specifyButton, title: "指定上传", filled: false)
view.addSubview(headerView)
headerView.addSubview(backButton)
headerView.addSubview(titleLabel)
headerView.addSubview(phoneLabel)
view.addSubview(statusCard)
statusCard.addSubview(storagePanel)
storagePanel.addSubview(storageTitleLabel)
storagePanel.addSubview(storageValueLabel)
statusCard.addSubview(statusLabel)
statusCard.addSubview(refreshButton)
statusCard.addSubview(helpLabel)
statusCard.addSubview(chipsStack)
statusCard.addSubview(statsCard)
view.addSubview(collectionView)
view.addSubview(emptyLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(batchButton)
bottomBar.addSubview(historyImportButton)
bottomBar.addSubview(specifyButton)
}
override func setupConstraints() {
headerView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(118)
}
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(8)
make.bottom.equalToSuperview().offset(-28)
make.size.equalTo(44)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(backButton.snp.trailing).offset(4)
make.trailing.equalToSuperview().offset(-16)
make.top.equalTo(backButton).offset(4)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
statusCard.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom).offset(-10)
make.leading.trailing.equalToSuperview().inset(16)
}
storagePanel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(14)
make.width.greaterThanOrEqualTo(76)
make.height.equalTo(76)
}
storageTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(8)
}
storageValueLabel.snp.makeConstraints { make in
make.top.equalTo(storageTitleLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(storageTitleLabel)
}
statusLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(storagePanel.snp.trailing).offset(10)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(84)
}
refreshButton.snp.makeConstraints { make in
make.centerY.equalTo(statusLabel)
make.trailing.equalToSuperview().offset(-14)
make.height.equalTo(34)
make.width.greaterThanOrEqualTo(72)
}
helpLabel.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(4)
make.leading.equalTo(statusLabel)
make.trailing.equalTo(refreshButton)
}
chipsStack.snp.makeConstraints { make in
make.top.equalTo(storagePanel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(14)
make.height.equalTo(34)
}
statsCard.snp.makeConstraints { make in
make.top.equalTo(chipsStack.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(68)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
batchButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(44)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
historyImportButton.snp.makeConstraints { make in
make.top.height.width.equalTo(batchButton)
make.leading.equalTo(batchButton.snp.trailing).offset(8)
}
specifyButton.snp.makeConstraints { make in
make.top.height.width.equalTo(batchButton)
make.leading.equalTo(historyImportButton.snp.trailing).offset(8)
make.trailing.equalToSuperview().offset(-16)
}
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()
make.bottom.equalTo(bottomBar.snp.top)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onLiveShotSaved = { [weak self] filename in
Task { @MainActor in self?.showToast("已保存 \(filename)") }
}
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
specifyButton.addTarget(self, action: #selector(specifyTapped), for: .touchUpInside)
formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in
UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) }
})
formatButton.showsMenuAsPrimaryAction = true
modeButton.menu = UIMenu(children: ["边拍边传", "拍后传输"].map { option in
UIAction(title: option) { [weak self] _ in self?.viewModel.selectTransferMode(option) }
})
modeButton.showsMenuAsPrimaryAction = true
helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped)))
}
@MainActor
private func applyViewModel() {
storageTitleLabel.text = viewModel.deviceModelName
storageValueLabel.text = viewModel.availableStorageText
statusLabel.text = viewModel.cameraStatusText
let isFailed = viewModel.cameraStatusText.contains("失败") || viewModel.cameraStatusText.contains("未连接")
statusLabel.textColor = isFailed ? AppColor.danger : AppColor.primary
statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10)
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
retouchButton.setTitle(viewModel.retouchOption, for: .normal)
formatButton.setTitle(viewModel.photoFormatOption.rawValue, for: .normal)
modeButton.setTitle(viewModel.transferModeOption, for: .normal)
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
rebuildStats()
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 rebuildStats() {
statsCard.arrangedSubviews.forEach { view in
statsCard.removeArrangedSubview(view)
view.removeFromSuperview()
}
for tab in TravelAlbumOTGTransferTab.allCases {
let selected = viewModel.selectedTab == tab
let button = makeStatButton(
title: tab.title,
count: viewModel.tabCounts[tab.rawValue],
selected: selected,
danger: tab == .failed
)
button.tag = tab.rawValue
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
statsCard.addArrangedSubview(button)
}
}
private func configureChipButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 11)
button.setTitleColor(AppColor.textTertiary, for: .normal)
button.backgroundColor = AppColor.pageBackground
button.layer.cornerRadius = 8
button.layer.borderWidth = 1
button.layer.borderColor = AppColor.border.cgColor
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
}
private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton {
var config = UIButton.Configuration.plain()
config.title = "\(count)\n\(title)"
config.titleAlignment = .center
config.baseForegroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
config.background.backgroundColor = .white
let button = UIButton(configuration: config)
button.titleLabel?.numberOfLines = 2
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .semibold : .regular)
return button
}
private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 15)
button.layer.cornerRadius = 10
if filled {
button.backgroundColor = AppColor.primary
button.setTitleColor(.white, for: .normal)
} else {
button.backgroundColor = .white
button.setTitleColor(AppColor.primary, for: .normal)
button.layer.borderWidth = 1
button.layer.borderColor = AppColor.primary.cgColor
}
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, 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: sonyHint ?? "未连接,查看",
attributes: [.foregroundColor: AppColor.textSecondary]
)
if sonyHint == nil {
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
}
return text
}
private func confirmDeletePhoto(_ photoId: String) {
let alert = UIAlertController(title: "删除照片", message: "仅删除本地 OTG 缓存,不会删除服务端已上传素材。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
self?.viewModel.deletePhoto(photoId: photoId)
})
present(alert, animated: true)
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func refreshTapped() {
viewModel.refreshCameraFiles()
}
@objc private func batchTapped() {
viewModel.onBatchUploadButtonClick()
}
@objc private func historyImportTapped() {
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
viewModel.pauseLiveTransferForHistoricalImport()
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
importController.onImportFinished = { [weak self] importedCount, failedCount in
guard let self else { return }
self.viewModel.reloadLocalPhotos()
self.viewModel.start()
if failedCount > 0 {
self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败")
} else {
self.showToast("已导入 \(importedCount) 张照片")
}
}
importController.onImportCancelled = { [weak self] in
self?.viewModel.start()
}
let navigationController = UINavigationController(rootViewController: importController)
navigationController.modalPresentationStyle = .pageSheet
present(navigationController, animated: true)
}
@objc private func specifyTapped() {
viewModel.onSpecifyUploadButtonClick()
}
@objc private func 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?()
}
}