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

939 lines
39 KiB
Swift
Raw Permalink 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.
//
// TravelAlbumAIRetouchTemplateViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
/// 页面使用的 collection section。
private enum Section: Hashable {
case overview
case templates(TravelAlbumAIRetouchTemplateCategory)
case mode
}
/// 页面使用的 diffable item。
private enum Item: Hashable {
case overview
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
case mode
}
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
private let api: any TravelAlbumServing
private let onSubmitted: (TravelAlbumAIJobSubmission) -> Void
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private let statusContainer = UIView()
private let statusIndicator = UIActivityIndicatorView(style: .medium)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let bottomBar = UIView()
private let bottomDivider = UIView()
private let footerStack = UIStackView()
private let validationLabel = UILabel()
private let actionStack = UIStackView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = AIRetouchGradientButton(type: .system)
/// 创建 AI 修图模板选择 Sheet。
init(
viewModel: TravelAlbumAIRetouchTemplateViewModel,
api: any TravelAlbumServing,
onSubmitted: @escaping (TravelAlbumAIJobSubmission) -> Void
) {
self.viewModel = viewModel
self.api = api
self.onSubmitted = onSubmitted
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
configureSheetPresentation()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupUI() {
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
collectionView.backgroundColor = .clear
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection"
collectionView.register(
TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchOverviewCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchModeCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchSectionHeader.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier
)
configureDataSource()
statusLabel.textColor = AIRetouchTemplateStyle.textSecondary
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
retryButton.setTitle("重试", for: .normal)
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton"
bottomBar.backgroundColor = .white
bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar"
bottomDivider.backgroundColor = AIRetouchTemplateStyle.border
validationLabel.textColor = AIRetouchTemplateStyle.danger
validationLabel.font = .systemFont(ofSize: 12, weight: .medium)
validationLabel.textAlignment = .center
validationLabel.numberOfLines = 0
validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel"
footerStack.axis = .vertical
footerStack.spacing = 8
footerStack.alignment = .fill
actionStack.axis = .horizontal
actionStack.spacing = 12
actionStack.distribution = .fill
actionStack.alignment = .fill
configureCancelButton()
configureConfirmButton()
actionStack.addArrangedSubview(cancelButton)
actionStack.addArrangedSubview(confirmButton)
footerStack.addArrangedSubview(validationLabel)
footerStack.addArrangedSubview(actionStack)
view.addSubview(collectionView)
view.addSubview(statusContainer)
statusContainer.addSubview(statusIndicator)
statusContainer.addSubview(statusLabel)
statusContainer.addSubview(retryButton)
view.addSubview(bottomBar)
bottomBar.addSubview(bottomDivider)
bottomBar.addSubview(footerStack)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
bottomDivider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
footerStack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
actionStack.snp.makeConstraints { make in
make.height.equalTo(56)
}
cancelButton.snp.makeConstraints { make in
make.width.equalTo(confirmButton.snp.width).multipliedBy(0.78)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
statusContainer.snp.makeConstraints { make in
make.edges.equalTo(collectionView)
}
statusIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-32)
}
statusLabel.snp.makeConstraints { make in
make.top.equalTo(statusIndicator.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(40)
}
retryButton.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(12)
make.centerX.equalToSuperview()
make.height.equalTo(36)
make.bottom.lessThanOrEqualToSuperview()
}
}
override func bindActions() {
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitted = { [weak self] submission in
Task { @MainActor in
guard let self else { return }
self.dismiss(animated: true) { self.onSubmitted(submission) }
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.loadTemplates(api: api) }
}
private func configureSheetPresentation() {
guard let sheet = sheetPresentationController else { return }
sheet.detents = [.large()]
sheet.selectedDetentIdentifier = .large
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
}
private func configureCancelButton() {
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary, for: .normal)
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary.withAlphaComponent(0.45), for: .disabled)
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
cancelButton.backgroundColor = AIRetouchTemplateStyle.cardBackground
cancelButton.layer.cornerRadius = 12
cancelButton.layer.borderColor = AIRetouchTemplateStyle.primary.cgColor
cancelButton.layer.borderWidth = 1
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
}
private func configureConfirmButton() {
confirmButton.setTitle("确定", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.setTitleColor(.white, for: .disabled)
confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
}
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
[weak self] collectionView, indexPath, item in
guard let self else { return nil }
switch item {
case .overview:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchOverviewCell
cell.apply(
selectedPhotoCount: self.viewModel.selectedPhotoCount,
tips: self.viewModel.shouldShowInitialTips ? self.viewModel.initialTipsText : nil
)
return cell
case .template(let category, let template):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchTemplateCell
cell.apply(
template: template,
selected: self.viewModel.selectedTemplateId(for: category) == template.id
)
cell.onPreviewTapped = { [weak self] in
self?.showPreview(for: template)
}
return cell
case .mode:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchModeCell
cell.apply(
requiredQuota: self.viewModel.requiredQuota,
remainingQuota: self.viewModel.remainingQuota,
insufficient: self.viewModel.remainingQuota.map {
self.viewModel.requiredQuota > $0
} ?? false
)
return cell
}
}
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard kind == UICollectionView.elementKindSectionHeader,
let self,
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section) else {
return nil
}
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchSectionHeader
switch section {
case .templates(let category):
header.apply(
title: category.title,
badge: category == .cover
? .requiredGift
: (self.viewModel.isOptional(category) ? .optional : .required)
)
case .mode:
return nil
case .overview:
return nil
}
return header
}
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
guard let self,
self.dataSource != nil,
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
return nil
}
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
case .overview:
let size = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(self.viewModel.shouldShowInitialTips ? 150 : 82)
)
let item = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
return NSCollectionLayoutSection(group: group)
case .templates:
let availableWidth = layoutEnvironment.container.effectiveContentSize.width
let cardWidth = min(118, floor((availableWidth - 56) / 3))
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(cardWidth),
heightDimension: .absolute(156)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.interGroupSpacing = 10
section.contentInsets = NSDirectionalEdgeInsets(
top: 4,
leading: 18,
bottom: 14,
trailing: 18
)
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(40)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
),
]
return section
case .mode:
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(78)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(
top: 8,
leading: 18,
bottom: 18,
trailing: 18
)
return section
}
}
}
@MainActor
private func applyViewModel() {
let isLoadFailure = viewModel.loadErrorMessage != nil
statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure
collectionView.isHidden = viewModel.isLoading || isLoadFailure
if viewModel.isLoading {
statusIndicator.startAnimating()
statusLabel.text = "正在加载修图模板…"
retryButton.isHidden = true
} else if let message = viewModel.loadErrorMessage {
statusIndicator.stopAnimating()
statusLabel.text = message
retryButton.isHidden = false
} else {
statusIndicator.stopAnimating()
statusLabel.text = nil
retryButton.isHidden = true
}
validationLabel.text = viewModel.validationMessage
validationLabel.isHidden = viewModel.validationMessage == nil
cancelButton.isEnabled = !viewModel.isSubmitting
confirmButton.isEnabled = viewModel.canSubmit
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
confirmButton.setGradientEnabled(viewModel.canSubmit)
confirmButton.setTitle(viewModel.isSubmitting ? "提交中" : "确定", for: .normal)
confirmButton.setLoading(viewModel.isSubmitting)
isModalInPresentation = viewModel.isSubmitting
applySnapshot()
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.overview])
snapshot.appendItems([.overview], toSection: .overview)
for category in viewModel.visibleCategories {
appendTemplates(category, to: &snapshot)
}
snapshot.appendSections([.mode])
snapshot.appendItems([.mode], toSection: .mode)
snapshot.reconfigureItems(snapshot.itemIdentifiers)
dataSource.apply(snapshot, animatingDifferences: true)
}
private func appendTemplates(
_ category: TravelAlbumAIRetouchTemplateCategory,
to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>
) {
let section = Section.templates(category)
snapshot.appendSections([section])
snapshot.appendItems(
viewModel.templates(for: category).map { Item.template(category, $0) },
toSection: section
)
}
@objc private func cancelTapped() {
guard !viewModel.isSubmitting else { return }
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard viewModel.canSubmit else {
if let message = viewModel.validationMessage { showToast(message) }
return
}
Task { await viewModel.submit(api: api) }
}
@objc private func retryTapped() {
Task { await viewModel.loadTemplates(api: api) }
}
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
guard let content = template.comparisonContent else {
showToast("暂无对比预览")
return
}
let controller = BeforeAfterComparisonViewController(
viewModel: BeforeAfterComparisonViewModel(content: content)
)
present(controller, animated: true)
}
}
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath),
case .template(let category, let template) = item else { return }
collectionView.scrollToItem(
at: indexPath,
at: .centeredHorizontally,
animated: !UIAccessibility.isReduceMotionEnabled
)
viewModel.toggleTemplate(id: template.id, category: category)
}
}
/// AI 修图模板页顶部说明卡,随模板列表一起滚动。
final class TravelAlbumAIRetouchOverviewCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchOverviewCell"
private let stackView = UIStackView()
private let titleLabel = UILabel()
private let selectionCountLabel = UILabel()
private let tipsContainer = UIView()
private let tipsIconContainer = UIView()
private let tipsIconView = UIImageView()
private let tipsLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.text = "AI修图"
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.textAlignment = .center
titleLabel.accessibilityTraits = .header
selectionCountLabel.textColor = AIRetouchTemplateStyle.textSecondary
selectionCountLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(
for: .systemFont(ofSize: 15, weight: .regular)
)
selectionCountLabel.adjustsFontForContentSizeCategory = true
selectionCountLabel.textAlignment = .center
selectionCountLabel.accessibilityIdentifier = "travelAlbum.aiRetouchSelectionCountLabel"
tipsContainer.backgroundColor = AIRetouchTemplateStyle.tipsBackground
tipsContainer.layer.cornerRadius = 12
tipsContainer.layer.borderWidth = 1
tipsContainer.layer.borderColor = AIRetouchTemplateStyle.tipsBorder.cgColor
tipsContainer.accessibilityIdentifier = "travelAlbum.aiRetouchTipsContainer"
tipsIconContainer.backgroundColor = AIRetouchTemplateStyle.primary
tipsIconContainer.layer.cornerRadius = 8
tipsIconView.image = UIImage(systemName: "wand.and.stars")
tipsIconView.tintColor = .white
tipsIconView.contentMode = .scaleAspectFit
tipsLabel.textColor = AIRetouchTemplateStyle.tipsText
tipsLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(
for: .systemFont(ofSize: 12, weight: .regular)
)
tipsLabel.adjustsFontForContentSizeCategory = true
tipsLabel.numberOfLines = 2
tipsLabel.accessibilityIdentifier = "travelAlbum.aiRetouchTipsLabel"
stackView.axis = .vertical
stackView.alignment = .fill
stackView.spacing = 2
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(selectionCountLabel)
stackView.addArrangedSubview(tipsContainer)
stackView.setCustomSpacing(14, after: selectionCountLabel)
contentView.addSubview(stackView)
tipsContainer.addSubview(tipsIconContainer)
tipsIconContainer.addSubview(tipsIconView)
tipsContainer.addSubview(tipsLabel)
stackView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.lessThanOrEqualToSuperview().offset(-8)
}
titleLabel.snp.makeConstraints { make in
make.height.equalTo(32)
}
selectionCountLabel.snp.makeConstraints { make in
make.height.equalTo(22)
}
tipsContainer.snp.makeConstraints { make in
make.height.equalTo(52)
}
tipsIconContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(32)
}
tipsIconView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(7)
}
tipsLabel.snp.makeConstraints { make in
make.leading.equalTo(tipsIconContainer.snp.trailing).offset(10)
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新选中照片数量和当前工作流说明。
func apply(selectedPhotoCount: Int, tips: String?) {
selectionCountLabel.text = "已选择 \(selectedPhotoCount) 张照片"
tipsLabel.text = tips
tipsContainer.isHidden = tips == nil
tipsIconContainer.isHidden = tips == nil
tipsLabel.isHidden = tips == nil
}
}
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
private let previewImageView = UIImageView()
private let nameLabel = UILabel()
private let checkView = UIImageView()
private let previewButton = AIRetouchTemplatePreviewButton(type: .system)
var onPreviewTapped: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = AIRetouchTemplateStyle.cardBackground
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.clipsToBounds = true
layer.shadowColor = UIColor(hex: 0x254A7A).cgColor
layer.shadowOpacity = 0.08
layer.shadowRadius = 7
layer.shadowOffset = CGSize(width: 0, height: 3)
layer.masksToBounds = false
previewImageView.contentMode = .scaleAspectFill
previewImageView.clipsToBounds = true
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
previewImageView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateImage"
nameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
nameLabel.textAlignment = .center
nameLabel.lineBreakMode = .byTruncatingTail
checkView.image = UIImage(systemName: "checkmark.circle.fill")
checkView.tintColor = AIRetouchTemplateStyle.primary
checkView.backgroundColor = .white
checkView.layer.cornerRadius = 11
var previewConfiguration = UIButton.Configuration.filled()
previewConfiguration.title = "预览"
previewConfiguration.baseForegroundColor = .white
previewConfiguration.baseBackgroundColor = UIColor.black.withAlphaComponent(0.58)
previewConfiguration.background.cornerRadius = 14
previewConfiguration.contentInsets = .zero
previewConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 12, weight: .regular)
return attributes
}
previewButton.configuration = previewConfiguration
previewButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewButton"
previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
contentView.addSubview(previewImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(checkView)
contentView.addSubview(previewButton)
previewImageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(118)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(previewImageView.snp.bottom)
make.leading.trailing.equalToSuperview().inset(6)
make.bottom.equalToSuperview()
}
checkView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(7)
make.size.equalTo(22)
}
previewButton.snp.makeConstraints { make in
make.trailing.bottom.equalTo(previewImageView).inset(4)
make.width.equalTo(48)
make.height.equalTo(28)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
previewImageView.kf.cancelDownloadTask()
previewImageView.image = nil
onPreviewTapped = nil
}
/// 更新模板内容与选中状态。
func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
nameLabel.text = template.name
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
previewImageView.contentMode = .scaleAspectFill
previewImageView.kf.setImage(
with: url,
placeholder: placeholder
)
} else {
previewImageView.image = placeholder
previewImageView.contentMode = .scaleAspectFill
}
contentView.layer.borderColor = (
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
).cgColor
contentView.layer.borderWidth = selected ? 2 : 1
layer.shadowOpacity = selected ? 0.16 : 0.08
checkView.isHidden = !selected
isSelected = selected
accessibilityLabel = template.name
accessibilityValue = selected ? "已选择" : "未选择"
accessibilityTraits = selected ? [.button, .selected] : [.button]
}
@objc private func previewTapped() {
onPreviewTapped?()
}
}
/// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。
private final class AIRetouchTemplatePreviewButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds.insetBy(dx: -8, dy: -8).contains(point)
}
}
/// 模板选择页主操作按钮,使用品牌蓝渐变并支持禁用态切换。
private final class AIRetouchGradientButton: UIButton {
private let gradientLayer = CAGradientLayer()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
override init(frame: CGRect) {
super.init(frame: frame)
gradientLayer.colors = [
AIRetouchTemplateStyle.primary.cgColor,
AIRetouchTemplateStyle.primaryGradientEnd.cgColor,
]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
gradientLayer.cornerRadius = 12
layer.insertSublayer(gradientLayer, at: 0)
activityIndicator.color = .white
activityIndicator.hidesWhenStopped = true
activityIndicator.isUserInteractionEnabled = false
addSubview(activityIndicator)
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
activityIndicator.center = CGPoint(x: bounds.midX - 44, y: bounds.midY)
}
/// 根据提交条件切换品牌渐变和禁用灰色。
func setGradientEnabled(_ enabled: Bool) {
gradientLayer.colors = enabled
? [
AIRetouchTemplateStyle.primary.cgColor,
AIRetouchTemplateStyle.primaryGradientEnd.cgColor,
]
: [
AIRetouchTemplateStyle.disabledStart.cgColor,
AIRetouchTemplateStyle.disabledEnd.cgColor,
]
}
/// 切换提交中状态,并在标题左侧展示活动指示器。
func setLoading(_ loading: Bool) {
if loading {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
}
setNeedsLayout()
}
}
/// AI 修图计费模式卡,展示按张收费规则与剩余额度。
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
private let iconContainer = UIView()
private let iconView = UIImageView()
private let modeLabel = UILabel()
private let quotaStack = UIStackView()
private let requiredQuotaContainer = UIView()
private let requiredQuotaLabel = UILabel()
private let quotaLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
contentView.layer.shadowColor = UIColor(hex: 0x254A7A).cgColor
contentView.layer.shadowOpacity = 0.06
contentView.layer.shadowRadius = 8
contentView.layer.shadowOffset = CGSize(width: 0, height: 3)
iconContainer.backgroundColor = AIRetouchTemplateStyle.primary
iconContainer.layer.cornerRadius = 10
iconView.image = UIImage(systemName: "wand.and.stars")
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
modeLabel.text = "AI精修 · 按张收费"
modeLabel.textColor = AIRetouchTemplateStyle.textPrimary
modeLabel.font = .systemFont(ofSize: 15, weight: .semibold)
quotaStack.axis = .vertical
quotaStack.alignment = .trailing
quotaStack.spacing = 3
requiredQuotaContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
requiredQuotaContainer.layer.cornerRadius = 12
requiredQuotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
requiredQuotaLabel.textColor = AIRetouchTemplateStyle.primary
requiredQuotaLabel.textAlignment = .center
requiredQuotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRequiredQuotaLabel"
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
quotaLabel.textAlignment = .right
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
contentView.addSubview(iconContainer)
iconContainer.addSubview(iconView)
contentView.addSubview(modeLabel)
contentView.addSubview(quotaStack)
quotaStack.addArrangedSubview(requiredQuotaContainer)
requiredQuotaContainer.addSubview(requiredQuotaLabel)
quotaStack.addArrangedSubview(quotaLabel)
iconContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14)
make.centerY.equalToSuperview()
make.size.equalTo(38)
}
iconView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(9)
}
modeLabel.snp.makeConstraints { make in
make.leading.equalTo(iconContainer.snp.trailing).offset(10)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(quotaStack.snp.leading).offset(-10)
}
quotaStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
}
requiredQuotaContainer.snp.makeConstraints { make in
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(108)
}
requiredQuotaLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新预计消耗和剩余精修张数,并在额度不足时切换为警示色。
func apply(requiredQuota: Int, remainingQuota: Int?, insufficient: Bool) {
requiredQuotaLabel.text = "预计消耗 \(requiredQuota) 张"
requiredQuotaLabel.accessibilityLabel = "预计消耗\(requiredQuota)张修图额度"
quotaLabel.text = remainingQuota.map { "剩余 \($0) 张" } ?? "剩余 -- 张"
quotaLabel.accessibilityLabel = remainingQuota.map { "剩余\($0)张修图额度" }
?? "剩余修图额度暂不可用"
requiredQuotaLabel.textColor = insufficient
? AIRetouchTemplateStyle.danger
: AIRetouchTemplateStyle.primary
requiredQuotaContainer.backgroundColor = requiredQuotaLabel.textColor.withAlphaComponent(0.1)
quotaLabel.textColor = insufficient ? AIRetouchTemplateStyle.danger : AIRetouchTemplateStyle.textSecondary
}
}
/// 模板分组标题右侧的业务标记。
fileprivate enum AIRetouchTemplateSectionBadge {
case required
case optional
case requiredGift
}
/// AI 修图模板分组标题,展示必选、选填或封面赠送规则。
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
private let titleLabel = UILabel()
private let badgeContainer = UIView()
private let badgeLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
badgeContainer.layer.cornerRadius = 10
badgeContainer.clipsToBounds = true
badgeLabel.font = .systemFont(ofSize: 11, weight: .medium)
badgeLabel.textAlignment = .center
badgeLabel.numberOfLines = 1
addSubview(titleLabel)
addSubview(badgeContainer)
badgeContainer.addSubview(badgeLabel)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalToSuperview()
}
badgeContainer.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalTo(titleLabel)
make.height.equalTo(20)
make.trailing.lessThanOrEqualToSuperview().offset(-18)
}
badgeLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(9)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新分组标题与业务标记。
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
titleLabel.text = title
badgeContainer.isHidden = badge == nil
switch badge {
case .required:
badgeLabel.text = "必选"
badgeLabel.textColor = AIRetouchTemplateStyle.danger
badgeContainer.backgroundColor = AIRetouchTemplateStyle.danger.withAlphaComponent(0.1)
case .optional:
badgeLabel.text = "选填"
badgeLabel.textColor = AIRetouchTemplateStyle.primary
badgeContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
case .requiredGift:
badgeLabel.text = "必选 · 赠送 · 不占额度"
badgeLabel.textColor = AIRetouchTemplateStyle.gift
badgeContainer.backgroundColor = AIRetouchTemplateStyle.giftBackground
case nil:
badgeLabel.text = nil
}
let badgeText = badgeLabel.text
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
accessibilityTraits = .header
}
}
/// AI 修图模板页视觉常量。
private enum AIRetouchTemplateStyle {
static let primary = UIColor(hex: 0x1677FF)
static let primaryGradientEnd = UIColor(hex: 0x4B9BFF)
static let pageBackground = UIColor(hex: 0xF7FAFF)
static let cardBackground = UIColor.white
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7D8799)
static let tipsBackground = UIColor(hex: 0xF4F8FF)
static let tipsBorder = UIColor(hex: 0xD9E7FA)
static let tipsText = UIColor(hex: 0x385778)
static let gift = UIColor(hex: 0xFF9418)
static let giftBackground = UIColor(hex: 0xFFF1D9)
static let border = UIColor(hex: 0xDCE4EF)
static let danger = UIColor(hex: 0xE53935)
static let disabledStart = UIColor(hex: 0xA9C8EE)
static let disabledEnd = UIColor(hex: 0xC6DAF3)
}