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

931 lines
38 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.
//
// 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: () -> 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 = UIButton(type: .system)
/// 创建 AI 修图模板选择 Sheet。
init(
viewModel: TravelAlbumAIRetouchTemplateViewModel,
api: any TravelAlbumServing,
onSubmitted: @escaping () -> 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 = .fillEqually
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)
}
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] in
Task { @MainActor in
guard let self else { return }
self.dismiss(animated: true, completion: self.onSubmitted)
}
}
}
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() {
var configuration = UIButton.Configuration.filled()
configuration.title = "取消"
configuration.baseBackgroundColor = .white
configuration.baseForegroundColor = AIRetouchTemplateStyle.textPrimary
configuration.background.cornerRadius = 12
configuration.background.strokeColor = UIColor(hex: 0xD1D1D6)
configuration.background.strokeWidth = 1
configuration.titleTextAttributesTransformer = buttonTitleTransformer
cancelButton.configuration = configuration
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
}
private func configureConfirmButton() {
var configuration = UIButton.Configuration.filled()
configuration.title = "确定"
configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary
configuration.baseForegroundColor = .white
configuration.background.cornerRadius = 12
configuration.titleTextAttributesTransformer = buttonTitleTransformer
confirmButton.configuration = configuration
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
}
private var buttonTitleTransformer: UIConfigurationTextAttributesTransformer {
UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
return attributes
}
}
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(
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
? .gift
: (self.viewModel.isOptional(category) ? .optional : nil)
)
case .mode:
header.apply(title: "修图模式", badge: nil)
case .overview:
return nil
}
return header
}
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ 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 ? 160 : 74)
)
let item = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
return NSCollectionLayoutSection(group: group)
case .templates:
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(118),
heightDimension: .absolute(154)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.interGroupSpacing = 12
section.contentInsets = NSDirectionalEdgeInsets(
top: 4,
leading: 18,
bottom: 12,
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(64)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(
top: 4,
leading: 18,
bottom: 18,
trailing: 18
)
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(42)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
),
]
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
var confirmConfiguration = confirmButton.configuration
confirmConfiguration?.title = viewModel.isSubmitting ? "提交中" : "确定"
confirmConfiguration?.showsActivityIndicator = viewModel.isSubmitting
confirmButton.configuration = confirmConfiguration
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) {
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
controller.modalPresentationStyle = .fullScreen
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 tipsLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.text = "AI修图"
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.accessibilityTraits = .header
selectionCountLabel.textColor = AIRetouchTemplateStyle.textSecondary
selectionCountLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(
for: .systemFont(ofSize: 17, weight: .regular)
)
selectionCountLabel.adjustsFontForContentSizeCategory = true
selectionCountLabel.textAlignment = .center
selectionCountLabel.accessibilityIdentifier = "travelAlbum.aiRetouchSelectionCountLabel"
tipsContainer.backgroundColor = AIRetouchTemplateStyle.tipsBackground
tipsContainer.layer.cornerRadius = 10
tipsContainer.accessibilityIdentifier = "travelAlbum.aiRetouchTipsContainer"
tipsLabel.textColor = AIRetouchTemplateStyle.tipsText
tipsLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(
for: .systemFont(ofSize: 13, weight: .regular)
)
tipsLabel.adjustsFontForContentSizeCategory = true
tipsLabel.numberOfLines = 0
tipsLabel.accessibilityIdentifier = "travelAlbum.aiRetouchTipsLabel"
stackView.axis = .vertical
stackView.alignment = .fill
stackView.spacing = 4
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(selectionCountLabel)
stackView.addArrangedSubview(tipsContainer)
stackView.setCustomSpacing(12, after: selectionCountLabel)
contentView.addSubview(stackView)
tipsContainer.addSubview(tipsLabel)
stackView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.lessThanOrEqualToSuperview().offset(-10)
}
titleLabel.snp.makeConstraints { make in
make.height.equalTo(30)
}
selectionCountLabel.snp.makeConstraints { make in
make.height.equalTo(24)
}
tipsLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12))
}
}
@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
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 = .white
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.clipsToBounds = true
previewImageView.contentMode = .scaleAspectFill
previewImageView.clipsToBounds = true
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
nameLabel.font = .systemFont(ofSize: 13, weight: .medium)
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 = 10
var previewConfiguration = UIButton.Configuration.filled()
previewConfiguration.title = "预览"
previewConfiguration.baseForegroundColor = .white
previewConfiguration.baseBackgroundColor = UIColor.black.withAlphaComponent(0.58)
previewConfiguration.background.cornerRadius = 15
previewConfiguration.contentInsets = .zero
previewConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 13, 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(116)
}
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(8)
make.size.equalTo(20)
}
previewButton.snp.makeConstraints { make in
make.trailing.bottom.equalTo(previewImageView).inset(8)
make.width.equalTo(58)
make.height.equalTo(32)
}
}
@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
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
previewImageView.contentMode = .scaleAspectFill
previewImageView.kf.setImage(
with: url,
placeholder: UIImage(systemName: "photo")?.withTintColor(
AIRetouchTemplateStyle.textSecondary,
renderingMode: .alwaysOriginal
)
)
} else {
previewImageView.image = UIImage(systemName: "photo")
previewImageView.tintColor = AIRetouchTemplateStyle.textSecondary
previewImageView.contentMode = .scaleAspectFit
}
contentView.layer.borderColor = (
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
).cgColor
contentView.layer.borderWidth = selected ? 2 : 1
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: -6, dy: -6).contains(point)
}
}
/// AI 修图计费模式卡,展示按张收费规则与剩余额度。
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
private let modeLabel = UILabel()
private let quotaContainer = UIView()
private let quotaLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 12
modeLabel.text = "AI精修 · 按张收费"
modeLabel.textColor = AIRetouchTemplateStyle.textPrimary
modeLabel.font = .systemFont(ofSize: 16, weight: .medium)
quotaContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
quotaContainer.layer.cornerRadius = 15
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
quotaLabel.textColor = AIRetouchTemplateStyle.primary
quotaLabel.textAlignment = .center
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
contentView.addSubview(modeLabel)
contentView.addSubview(quotaContainer)
quotaContainer.addSubview(quotaLabel)
modeLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(quotaContainer.snp.leading).offset(-12)
}
quotaContainer.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.width.equalTo(96)
make.height.equalTo(30)
}
quotaLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新剩余可精修张数,并在额度不足时切换为警示色。
func apply(remainingQuota: Int?, insufficient: Bool) {
quotaLabel.text = remainingQuota.map { "剩余 \($0) 张" } ?? "剩余 -- 张"
quotaLabel.accessibilityLabel = remainingQuota.map { "剩余\($0)张修图额度" }
?? "剩余修图额度暂不可用"
quotaLabel.textColor = insufficient
? AIRetouchTemplateStyle.danger
: (remainingQuota == nil ? AIRetouchTemplateStyle.textSecondary : AIRetouchTemplateStyle.primary)
quotaContainer.backgroundColor = quotaLabel.textColor.withAlphaComponent(0.1)
}
}
/// 模板分组标题右侧的业务标记。
fileprivate enum AIRetouchTemplateSectionBadge {
case optional
case gift
}
/// AI 修图模板分组标题,可附带选填或赠送标记。
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
private let titleLabel = UILabel()
private let optionalLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
optionalLabel.textAlignment = .center
optionalLabel.layer.cornerRadius = 10
optionalLabel.clipsToBounds = true
addSubview(titleLabel)
addSubview(optionalLabel)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalToSuperview()
}
optionalLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalTo(titleLabel)
make.width.greaterThanOrEqualTo(38)
make.height.equalTo(20)
make.trailing.lessThanOrEqualToSuperview().offset(-18)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新分组标题与业务标记。
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
titleLabel.text = title
optionalLabel.isHidden = badge == nil
switch badge {
case .optional:
optionalLabel.text = " 选填 "
optionalLabel.textColor = AIRetouchTemplateStyle.primary
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
case .gift:
optionalLabel.text = " 赠送 · 不占精修张数 "
optionalLabel.textColor = AIRetouchTemplateStyle.gift
optionalLabel.backgroundColor = AIRetouchTemplateStyle.giftBackground
case nil:
optionalLabel.text = nil
}
let badgeText = optionalLabel.text?.trimmingCharacters(in: .whitespaces)
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
accessibilityTraits = .header
}
}
/// AI 修图模板全屏预览,支持双指缩放查看模板细节。
final class TravelAlbumAIRetouchTemplatePreviewViewController: UIViewController {
private let template: TravelAlbumAIRetouchTemplate
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let scrollView = UIScrollView()
private let imageView = UIImageView()
/// 创建指定模板的全屏预览页。
init(template: TravelAlbumAIRetouchTemplate) {
self.template = template
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
loadPreview()
}
private func setupUI() {
view.backgroundColor = .black
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreview"
var backConfiguration = UIButton.Configuration.filled()
backConfiguration.image = UIImage(systemName: "chevron.left")
backConfiguration.baseForegroundColor = .white
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
backConfiguration.background.cornerRadius = 24
backButton.configuration = backConfiguration
backButton.accessibilityLabel = "返回模板选择"
backButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewBackButton"
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
titleLabel.text = template.name
titleLabel.textColor = .white
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.lineBreakMode = .byTruncatingTail
subtitleLabel.text = "双指缩放查看细节"
subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.52)
subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
subtitleLabel.textAlignment = .center
scrollView.minimumZoomScale = 1
scrollView.maximumZoomScale = 4
scrollView.delegate = self
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewScrollView"
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.backgroundColor = UIColor(hex: 0x111111)
imageView.accessibilityLabel = "\(template.name)模板预览图"
view.addSubview(backButton)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(scrollView)
scrollView.addSubview(imageView)
}
private func setupConstraints() {
backButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
make.leading.equalToSuperview().offset(18)
make.size.equalTo(48)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12)
make.centerX.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview().offset(-66)
make.height.equalTo(26)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2)
make.centerX.equalToSuperview()
}
scrollView.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
make.leading.trailing.bottom.equalToSuperview()
}
imageView.snp.makeConstraints { make in
make.center.equalTo(scrollView.frameLayoutGuide)
make.width.equalTo(scrollView.frameLayoutGuide)
make.height.equalTo(imageView.snp.width).multipliedBy(0.75)
}
}
private func loadPreview() {
guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = UIColor.white.withAlphaComponent(0.45)
return
}
imageView.kf.setImage(
with: url,
placeholder: UIImage(systemName: "photo")?.withTintColor(
UIColor.white.withAlphaComponent(0.45),
renderingMode: .alwaysOriginal
)
)
}
@objc private func backTapped() {
dismiss(animated: true)
}
}
extension TravelAlbumAIRetouchTemplatePreviewViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
}
/// AI 修图模板页视觉常量。
private enum AIRetouchTemplateStyle {
static let primary = UIColor(hex: 0x1677FF)
static let pageBackground = UIColor(hex: 0xF7F7F7)
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x6E6E73)
static let tipsBackground = UIColor(hex: 0xEAF3FF)
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)
}