feat: add travel album OTG import flow
This commit is contained in:
@ -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?()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user