Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
2026-07-31 16:48:28 +08:00

801 lines
34 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.

//
// TravelAlbumDetailViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `TravelAlbumDetailScreen`
final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing
private let aiEditResultStore = TravelAlbumAIEditResultStore.shared
private var pendingAIEditMaterialIDs: Set<Int> = []
private var failedAIEditCount = 0
private let scrollContainer = UIView()
private let infoCard = TravelAlbumInfoCard()
private let manageCard = UIView()
private let tabStack = UIStackView()
private let allTabButton = UIButton(type: .system)
private let purchasedTabButton = UIButton(type: .system)
private let sortButton = UIButton(type: .system)
private let selectButton = UIButton(type: .system)
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
private let bottomBar = UIView()
private let bottomActionStack = UIStackView()
private let aiEditSelectedButton = UIButton(type: .system)
private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system)
init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "相册管理"
navigationItem.backButtonDisplayMode = .minimal
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.confirmDeleteAlbum()
},
])
)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
manageCard.backgroundColor = .white
manageCard.layer.cornerRadius = 12
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
manageCard.layer.shadowOpacity = 1
manageCard.layer.shadowRadius = 6
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
tabStack.axis = .horizontal
tabStack.spacing = 8
configurePillButton(allTabButton)
configurePillButton(purchasedTabButton)
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill"))
configureIconButton(selectButton, image: UIImage(systemName: "circle"))
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white
collectionView.delegate = self
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
[weak self] collectionView, indexPath, material in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumMaterialCell
let aiEditState = self?.aiEditResultStore.state(for: material.id)
?? TravelAlbumAIEditResultState()
let resultImage = aiEditState.isCover
? (self?.aiEditResultStore.coverImage(for: material.id)
?? self?.aiEditResultStore.editedImage(for: material.id))
: self?.aiEditResultStore.editedImage(for: material.id)
cell.apply(
material: material,
selectionMode: self?.viewModel.isSelectionMode == true,
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true,
aiEditState: aiEditState,
editedImage: resultImage
)
return cell
}
bottomBar.backgroundColor = AppColor.pageBackground
bottomActionStack.axis = .horizontal
bottomActionStack.spacing = 12
bottomActionStack.distribution = .fillEqually
configureSelectionActionButton(
aiEditSelectedButton,
title: "AI修图",
image: UIImage(named: "travel_album_preview_ai_edit")?.withRenderingMode(.alwaysTemplate)
)
configureSelectionActionButton(
deleteSelectedButton,
title: "删除",
image: UIImage(named: "travel_album_preview_delete")?.withRenderingMode(.alwaysTemplate)
)
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
aiEditSelectedButton.isHidden = true
deleteSelectedButton.isHidden = true
view.addSubview(scrollContainer)
scrollContainer.addSubview(infoCard)
scrollContainer.addSubview(manageCard)
manageCard.addSubview(tabStack)
tabStack.addArrangedSubview(allTabButton)
tabStack.addArrangedSubview(purchasedTabButton)
manageCard.addSubview(sortButton)
manageCard.addSubview(selectButton)
manageCard.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(bottomActionStack)
bottomActionStack.addArrangedSubview(aiEditSelectedButton)
bottomActionStack.addArrangedSubview(deleteSelectedButton)
bottomActionStack.addArrangedSubview(uploadButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
bottomActionStack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(20)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
[aiEditSelectedButton, deleteSelectedButton, uploadButton].forEach { button in
button.snp.makeConstraints { make in
make.height.equalTo(58).priority(.high)
}
}
scrollContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
}
infoCard.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.greaterThanOrEqualTo(76)
}
manageCard.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview()
}
tabStack.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12)
make.height.equalTo(32)
}
sortButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
make.size.equalTo(32)
}
selectButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.trailing.equalToSuperview().offset(-12)
make.size.equalTo(32)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
}
}
override func bindActions() {
infoCard.onCall = { [weak self] phone in self?.call(phone) }
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
aiEditSelectedButton.addTarget(self, action: #selector(aiEditSelectedTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
NotificationCenter.default.addObserver(
self,
selector: #selector(aiEditResultDidChange(_:)),
name: TravelAlbumAIEditResultStore.didChangeNotification,
object: aiEditResultStore
)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onDeletedAlbum = { [weak self] _ in
Task { @MainActor in
guard let self else { return }
TravelAlbumOTGPhotoStore().clearAlbum(albumId: self.viewModel.albumId)
self.navigationController?.popViewController(animated: true)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refreshAll(api: api) }
}
@MainActor
private func applyViewModel() {
if let album = viewModel.album {
infoCard.apply(album: album)
}
configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased)
selectButton.isHidden = viewModel.selectedTab != .all
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
updateBottomToolbar()
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.materials)
if !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.isLoading && viewModel.album == nil {
showLoading()
} else {
hideLoading()
}
}
///
private func updateBottomToolbar() {
let toolbarState = TravelAlbumSelectionToolbarState(
isSelectionMode: viewModel.isSelectionMode,
selectedCount: viewModel.selectedMaterialIds.count
)
uploadButton.isHidden = !toolbarState.showsUpload
aiEditSelectedButton.isHidden = !toolbarState.showsSelectionActions
deleteSelectedButton.isHidden = !toolbarState.showsSelectionActions
let selectedContainsProcessingPhoto = viewModel.selectedMaterialIds.contains { materialID in
aiEditResultStore.state(for: materialID).isProcessing
}
let canStartAIEditing = toolbarState.selectionActionsEnabled
&& pendingAIEditMaterialIDs.isEmpty
&& !selectedContainsProcessingPhoto
aiEditSelectedButton.isEnabled = canStartAIEditing
deleteSelectedButton.isEnabled = toolbarState.selectionActionsEnabled
aiEditSelectedButton.alpha = canStartAIEditing ? 1 : 0.4
deleteSelectedButton.alpha = toolbarState.selectionActionsEnabled ? 1 : 0.4
bottomBar.backgroundColor = toolbarState.showsSelectionActions ? .white : AppColor.pageBackground
}
private func configurePillButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14)
)
}
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
button.setTitle(title, for: .normal)
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF)
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal)
}
private func configureIconButton(_ button: UIButton, image: UIImage?) {
button.setImage(image, for: .normal)
button.backgroundColor = UIColor(hex: 0xEAF4FF)
button.tintColor = AppColor.primary
button.layer.cornerRadius = 16
}
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = color
button.layer.cornerRadius = 10
}
/// Iconfont
private func configureSelectionActionButton(_ button: UIButton, title: String, image: UIImage?) {
var configuration = UIButton.Configuration.plain()
configuration.title = title
configuration.image = image
configuration.imagePlacement = .top
configuration.imagePadding = 5
configuration.baseForegroundColor = AppColor.textSecondary
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 13, weight: .medium)
return attributes
}
button.configuration = configuration
}
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 + 38))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 38))
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 call(_ phone: String) {
guard !phone.isEmpty, let url = URL(string: "tel:\(phone)") else { return }
UIApplication.shared.open(url)
}
private func confirmDeleteAlbum() {
let alert = UIAlertController(title: "删除相册", message: "确定删除该相册吗?已有购买素材的相册无法删除。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.deleteAlbum(api: self.api) }
})
present(alert, animated: true)
}
@objc private func allTabTapped() {
Task { await viewModel.selectTab(.all, api: api) }
}
@objc private func purchasedTabTapped() {
Task { await viewModel.selectTab(.purchased, api: api) }
}
@objc private func sortTapped() {
let alert = UIAlertController(title: "排序", message: nil, preferredStyle: .actionSheet)
TravelAlbumDetailViewModel.SortOption.allCases.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setSortOption(option, api: self.api) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func selectTapped() {
viewModel.toggleSelectionMode()
}
@objc private func aiEditSelectedTapped() {
let selectedMaterials = viewModel.materials.filter { viewModel.selectedMaterialIds.contains($0.id) }
guard let firstMaterial = selectedMaterials.first else {
showToast("请选择要修图的照片")
return
}
let imageURLString = firstMaterial.coverUrl.isEmpty ? firstMaterial.fileUrl : firstMaterial.coverUrl
guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
presentAIEditSheet(sourceImage: nil, selectedMaterials: selectedMaterials)
return
}
KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
guard let self else { return }
Task { @MainActor in
let sourceImage: UIImage?
if case let .success(value) = result {
sourceImage = value.image
} else {
sourceImage = nil
}
self.presentAIEditSheet(sourceImage: sourceImage, selectedMaterials: selectedMaterials)
}
}
}
/// AI
private func presentAIEditSheet(
sourceImage: UIImage?,
selectedMaterials: [TravelAlbumMaterial]
) {
let controller = TravelAlbumAIEditSheetViewController(
sourceImage: sourceImage,
selectedPhotoCount: selectedMaterials.count
)
controller.onConfirm = { [weak self] selection in
self?.startBatchAIEditing(
materials: selectedMaterials,
selection: selection
)
}
present(controller, animated: true)
}
/// AI
private func startBatchAIEditing(
materials: [TravelAlbumMaterial],
selection: TravelAlbumAIEditSelection
) {
guard pendingAIEditMaterialIDs.isEmpty, !materials.isEmpty else { return }
let materialIDs = materials.map(\.id)
let coverMaterialID = selection.coverTemplate == nil ? nil : materials.first?.id
pendingAIEditMaterialIDs = Set(materialIDs)
failedAIEditCount = 0
aiEditResultStore.startProcessing(materialIDs: materialIDs)
applyViewModel()
materials.forEach { material in
let imageURLString = material.fileUrl.isEmpty ? material.coverUrl : material.fileUrl
guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
finishAIEditing(
materialID: material.id,
selection: selection,
appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
sourceImage: nil
)
return
}
KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
guard let self else { return }
Task { @MainActor in
let sourceImage: UIImage?
if case let .success(value) = result {
sourceImage = value.image
} else {
sourceImage = nil
}
self.finishAIEditing(
materialID: material.id,
selection: selection,
appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
sourceImage: sourceImage
)
}
}
}
}
///
private func finishAIEditing(
materialID: Int,
selection: TravelAlbumAIEditSelection,
appliedCoverTemplate: TravelAlbumCoverTemplate?,
sourceImage: UIImage?
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
guard let self else { return }
if let sourceImage {
let refinedImage = TravelAlbumAIEditImageProcessor.render(
effect: selection.refinedPreset.effect,
source: sourceImage
)
let atmosphereImage = selection.atmosphereOption.map { option in
TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: sourceImage)
}
let coverImage = appliedCoverTemplate.map { template in
TravelAlbumCoverTemplateImageProcessor.render(
template: template,
source: refinedImage
)
}
if selection.refinedPreset.effect == .original,
selection.atmosphereOption == nil,
appliedCoverTemplate == nil {
self.aiEditResultStore.restoreOriginal(materialID: materialID)
} else {
self.aiEditResultStore.complete(
materialID: materialID,
presetID: selection.refinedPreset.id,
atmosphereOptionID: selection.atmosphereOption?.id,
coverTemplateID: appliedCoverTemplate?.id,
editedImage: refinedImage,
atmosphereImage: atmosphereImage,
coverImage: coverImage
)
}
} else {
self.aiEditResultStore.failProcessing(materialID: materialID)
self.failedAIEditCount += 1
}
self.pendingAIEditMaterialIDs.remove(materialID)
self.applyViewModel()
if self.pendingAIEditMaterialIDs.isEmpty {
let successMessage: String
if let coverTemplate = selection.coverTemplate,
selection.atmosphereOption != nil {
successMessage = "AI修图完成每张生成 2 个结果,并生成「\(coverTemplate.title)」封面"
} else if let coverTemplate = selection.coverTemplate {
successMessage = "AI修图完成已生成「\(coverTemplate.title)」封面"
} else if selection.atmosphereOption != nil {
successMessage = "AI修图完成每张已生成 2 个结果"
} else {
successMessage = "AI修图完成"
}
let message = self.failedAIEditCount == 0
? successMessage
: "\(self.failedAIEditCount) 张照片修图失败,请重试"
self.showToast(message)
}
}
}
@objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.deleteSelectedMaterials(api: self.api) }
})
present(alert, animated: true)
}
@objc private func uploadTapped() {
let controller = TravelAlbumTransferModeSheetViewController()
controller.onModeSelected = { [weak self] mode in
self?.openWiredTransfer(mode: mode)
}
present(controller, animated: true)
}
///
private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
let album = viewModel.album
let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "",
headerPhone: album?.displayPhone ?? "",
initialTransferMode: mode
)
)
navigationController?.pushViewController(controller, animated: true)
}
///
@objc private func aiEditResultDidChange(_ notification: Notification) {
guard let materialID = notification.userInfo?[TravelAlbumAIEditResultStore.materialIDUserInfoKey] as? Int,
let material = dataSource.snapshot().itemIdentifiers.first(where: { $0.id == materialID }) else {
return
}
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([material])
dataSource.apply(snapshot, animatingDifferences: true)
updateBottomToolbar()
}
}
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
if viewModel.isSelectionMode {
viewModel.toggleMaterialSelection(material)
} else {
//
let initialIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) ?? 0
let controller = TravelAlbumPhotoPreviewViewController(
materials: viewModel.materials,
initialIndex: initialIndex,
api: api
)
controller.onDeleted = { [weak self] in
guard let self else { return }
Task { await self.viewModel.refreshAll(api: self.api) }
}
//
present(controller, animated: true)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let visible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? 0
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: visible, api: api) }
}
}
///
private final class TravelAlbumInfoCard: UIView {
var onCall: ((String) -> Void)?
private var phone = ""
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
private let phoneLabel = UILabel()
private let timeLabel = UILabel()
private let callButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
layer.shadowOpacity = 1
layer.shadowRadius = 6
layer.shadowOffset = CGSize(width: 0, height: 2)
let iconBox = UIView()
iconBox.backgroundColor = AppColor.primary
iconBox.layer.cornerRadius = 10
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
phoneLabel.font = .systemFont(ofSize: 14, weight: .medium)
phoneLabel.textColor = AppColor.textPrimary
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = AppColor.textTertiary
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary
callButton.backgroundColor = UIColor(hex: 0xEAF4FF)
callButton.layer.cornerRadius = 20
addSubview(iconBox)
iconBox.addSubview(iconView)
addSubview(phoneLabel)
addSubview(timeLabel)
addSubview(callButton)
iconBox.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14)
make.size.equalTo(48)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(iconBox.snp.trailing).offset(12)
make.trailing.equalTo(callButton.snp.leading).offset(-12)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(phoneLabel)
make.bottom.equalToSuperview().offset(-16)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(40)
}
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(album: TravelAlbum) {
phone = album.displayPhone
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))"
timeLabel.text = "创建时间 \(album.createdAt)"
}
@objc private func callTapped() {
onCall?(phone)
}
}
/// cell
private final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView()
private let statusLabel = UILabel()
private let checkImageView = UIImageView()
private let nameLabel = UILabel()
private let sizeLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 8
statusLabel.text = "已上传"
statusLabel.font = .systemFont(ofSize: 10)
statusLabel.textColor = .white
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
checkImageView.tintColor = .white
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 10
nameLabel.font = .systemFont(ofSize: 11)
nameLabel.textColor = AppColor.textPrimary
nameLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary
contentView.addSubview(imageView)
imageView.addSubview(statusLabel)
imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel)
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width)
}
statusLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(4)
make.height.equalTo(18)
make.width.equalTo(48)
}
checkImageView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(20)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview()
}
sizeLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
}
func apply(
material: TravelAlbumMaterial,
selectionMode: Bool,
selected: Bool,
aiEditState: TravelAlbumAIEditResultState,
editedImage: UIImage?
) {
imageView.backgroundColor = .clear
imageView.tintColor = nil
if aiEditState.hasEditedResult, let editedImage {
imageView.kf.cancelDownloadTask()
imageView.image = editedImage
} else if let assetName = material.bundledImageAssetName,
let bundledImage = UIImage(named: assetName) {
imageView.kf.cancelDownloadTask()
imageView.image = bundledImage
} else {
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
if let url = URL(string: urlString), !urlString.isEmpty {
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.primary
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
}
}
applyAIEditStatus(aiEditState.thumbnailStatus, isPurchased: material.isPurchased)
checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white
nameLabel.text = material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
}
/// AI
private func applyAIEditStatus(
_ status: TravelAlbumAIEditResultState.ThumbnailStatus,
isPurchased: Bool
) {
let appearance: (title: String, color: UIColor, width: CGFloat)
switch status {
case .none:
appearance = isPurchased
? ("已购", AppColor.primary, 48)
: ("已上传", UIColor(hex: 0x34C759), 48)
case .processing:
appearance = ("修图中", AppColor.primary, 48)
case .edited:
appearance = ("AI已修", UIColor(hex: 0x6750A4), 48)
case .cover:
appearance = ("AI封面", UIColor(hex: 0xF59E0B), 56)
case .failed:
appearance = ("修图失败", UIColor(hex: 0xE53935), 56)
}
statusLabel.text = appearance.title
statusLabel.backgroundColor = appearance.color
statusLabel.snp.updateConstraints { make in
make.width.equalTo(appearance.width)
}
}
}