feat: 优化AI修图模板选择界面

This commit is contained in:
2026-08-11 17:04:51 +08:00
parent aca19a53fb
commit 010add828b
4 changed files with 593 additions and 77 deletions
@@ -52,6 +52,28 @@ final class TravelAlbumAIRetouchTemplateViewModel {
workflow.visibleCategories workflow.visibleCategories
} }
/// 当前工作流选择的原始照片数量。
var selectedPhotoCount: Int {
switch workflow {
case .initial(_, let materialIds): materialIds.count
case .reretouch: 1
}
}
/// 是否展示首次修图的氛围感与封面生成说明。
var shouldShowInitialTips: Bool {
if case .initial = workflow { return true }
return false
}
/// 首次修图页按实际展示分组生成的规则说明。
var initialTipsText: String {
if visibleCategories.contains(.cover) {
return "Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。"
}
return "Tips:本次显示 2 类风格。氛围感修图为选填;选中后每张照片会生成「精修后 + 氛围感」2 个独立结果。"
}
/// 当前分组是否为选填。 /// 当前分组是否为选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
workflow.isOptional(category) workflow.isOptional(category)
@@ -11,19 +11,22 @@ import UIKit
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
/// 页面使用的 collection section。 /// 页面使用的 collection section。
private enum Section: Hashable { private enum Section: Hashable {
case overview
case templates(TravelAlbumAIRetouchTemplateCategory) case templates(TravelAlbumAIRetouchTemplateCategory)
case mode
} }
/// 页面使用的 diffable item。 /// 页面使用的 diffable item。
private enum Item: Hashable { private enum Item: Hashable {
case overview
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate) case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
case mode
} }
private let viewModel: TravelAlbumAIRetouchTemplateViewModel private let viewModel: TravelAlbumAIRetouchTemplateViewModel
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private let onSubmitted: () -> Void private let onSubmitted: () -> Void
private let titleLabel = UILabel()
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private let statusContainer = UIView() private let statusContainer = UIView()
@@ -33,7 +36,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
private let bottomBar = UIView() private let bottomBar = UIView()
private let bottomDivider = UIView() private let bottomDivider = UIView()
private let footerStack = UIStackView() private let footerStack = UIStackView()
private let quotaLabel = UILabel()
private let validationLabel = UILabel() private let validationLabel = UILabel()
private let actionStack = UIStackView() private let actionStack = UIStackView()
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
@@ -62,12 +64,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
view.backgroundColor = AIRetouchTemplateStyle.pageBackground view.backgroundColor = AIRetouchTemplateStyle.pageBackground
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet" view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
titleLabel.text = "AI修图"
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.accessibilityTraits = .header
collectionView.backgroundColor = .clear collectionView.backgroundColor = .clear
collectionView.showsVerticalScrollIndicator = false collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceVertical = true collectionView.alwaysBounceVertical = true
@@ -77,6 +73,14 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
TravelAlbumAIRetouchTemplateCell.self, TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
) )
collectionView.register(
TravelAlbumAIRetouchOverviewCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchModeCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
)
collectionView.register( collectionView.register(
TravelAlbumAIRetouchSectionHeader.self, TravelAlbumAIRetouchSectionHeader.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
@@ -104,10 +108,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
footerStack.axis = .vertical footerStack.axis = .vertical
footerStack.spacing = 8 footerStack.spacing = 8
footerStack.alignment = .fill footerStack.alignment = .fill
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
quotaLabel.textColor = AIRetouchTemplateStyle.primary
quotaLabel.textAlignment = .center
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
actionStack.axis = .horizontal actionStack.axis = .horizontal
actionStack.spacing = 12 actionStack.spacing = 12
actionStack.distribution = .fillEqually actionStack.distribution = .fillEqually
@@ -116,11 +116,9 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
configureConfirmButton() configureConfirmButton()
actionStack.addArrangedSubview(cancelButton) actionStack.addArrangedSubview(cancelButton)
actionStack.addArrangedSubview(confirmButton) actionStack.addArrangedSubview(confirmButton)
footerStack.addArrangedSubview(quotaLabel)
footerStack.addArrangedSubview(validationLabel) footerStack.addArrangedSubview(validationLabel)
footerStack.addArrangedSubview(actionStack) footerStack.addArrangedSubview(actionStack)
view.addSubview(titleLabel)
view.addSubview(collectionView) view.addSubview(collectionView)
view.addSubview(statusContainer) view.addSubview(statusContainer)
statusContainer.addSubview(statusIndicator) statusContainer.addSubview(statusIndicator)
@@ -132,11 +130,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
} }
override func setupConstraints() { override func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(18)
make.height.equalTo(30)
}
bottomBar.snp.makeConstraints { make in bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
@@ -150,10 +143,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
} }
actionStack.snp.makeConstraints { make in actionStack.snp.makeConstraints { make in
make.height.equalTo(48) make.height.equalTo(56)
} }
collectionView.snp.makeConstraints { make in collectionView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8) make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top) make.bottom.equalTo(bottomBar.snp.top)
} }
@@ -212,9 +205,9 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
var configuration = UIButton.Configuration.filled() var configuration = UIButton.Configuration.filled()
configuration.title = "取消" configuration.title = "取消"
configuration.baseBackgroundColor = .white configuration.baseBackgroundColor = .white
configuration.baseForegroundColor = AIRetouchTemplateStyle.primary configuration.baseForegroundColor = AIRetouchTemplateStyle.textPrimary
configuration.background.cornerRadius = 14 configuration.background.cornerRadius = 12
configuration.background.strokeColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.55) configuration.background.strokeColor = UIColor(hex: 0xD1D1D6)
configuration.background.strokeWidth = 1 configuration.background.strokeWidth = 1
configuration.titleTextAttributesTransformer = buttonTitleTransformer configuration.titleTextAttributesTransformer = buttonTitleTransformer
cancelButton.configuration = configuration cancelButton.configuration = configuration
@@ -226,7 +219,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
configuration.title = "确定" configuration.title = "确定"
configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary
configuration.baseForegroundColor = .white configuration.baseForegroundColor = .white
configuration.background.cornerRadius = 14 configuration.background.cornerRadius = 12
configuration.titleTextAttributesTransformer = buttonTitleTransformer configuration.titleTextAttributesTransformer = buttonTitleTransformer
confirmButton.configuration = configuration confirmButton.configuration = configuration
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton" confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
@@ -245,6 +238,16 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
[weak self] collectionView, indexPath, item in [weak self] collectionView, indexPath, item in
guard let self else { return nil } guard let self else { return nil }
switch item { 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): case .template(let category, let template):
let cell = collectionView.dequeueReusableCell( let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier, withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
@@ -254,22 +257,49 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
template: template, template: template,
selected: self.viewModel.selectedTemplateId(for: category) == template.id 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 return cell
} }
} }
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard kind == UICollectionView.elementKindSectionHeader, guard kind == UICollectionView.elementKindSectionHeader,
let self, let self,
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section), self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section) else {
case .templates(let category) = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] else {
return nil return nil
} }
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
let header = collectionView.dequeueReusableSupplementaryView( let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind, ofKind: kind,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier, withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
for: indexPath for: indexPath
) as! TravelAlbumAIRetouchSectionHeader ) as! TravelAlbumAIRetouchSectionHeader
header.apply(title: category.title, optional: self.viewModel.isOptional(category)) 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 return header
} }
} }
@@ -281,27 +311,68 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else { self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
return nil return nil
} }
let itemSize = NSCollectionLayoutSize( switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
widthDimension: .absolute(118), case .overview:
heightDimension: .absolute(154) let size = NSCollectionLayoutSize(
) widthDimension: .fractionalWidth(1),
let item = NSCollectionLayoutItem(layoutSize: itemSize) heightDimension: .absolute(self.viewModel.shouldShowInitialTips ? 160 : 74)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) )
let section = NSCollectionLayoutSection(group: group) let item = NSCollectionLayoutItem(layoutSize: size)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
section.interGroupSpacing = 12 return NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18) case .templates:
section.boundarySupplementaryItems = [ let itemSize = NSCollectionLayoutSize(
NSCollectionLayoutBoundarySupplementaryItem( widthDimension: .absolute(118),
layoutSize: NSCollectionLayoutSize( heightDimension: .absolute(154)
widthDimension: .fractionalWidth(1), )
heightDimension: .absolute(40) 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
), ),
elementKind: UICollectionView.elementKindSectionHeader, ]
alignment: .top return section
), case .mode:
] let itemSize = NSCollectionLayoutSize(
return section 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
}
} }
} }
@@ -326,17 +397,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
validationLabel.text = viewModel.validationMessage validationLabel.text = viewModel.validationMessage
validationLabel.isHidden = viewModel.validationMessage == nil validationLabel.isHidden = viewModel.validationMessage == nil
if let remainingQuota = viewModel.remainingQuota {
quotaLabel.text = "剩余 \(remainingQuota) 次"
quotaLabel.accessibilityLabel = "剩余\(remainingQuota)次修图额度"
quotaLabel.textColor = viewModel.requiredQuota > remainingQuota
? AIRetouchTemplateStyle.danger
: AIRetouchTemplateStyle.primary
} else {
quotaLabel.text = "剩余 -- 次"
quotaLabel.accessibilityLabel = "剩余修图额度暂不可用"
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
}
cancelButton.isEnabled = !viewModel.isSubmitting cancelButton.isEnabled = !viewModel.isSubmitting
confirmButton.isEnabled = viewModel.canSubmit confirmButton.isEnabled = viewModel.canSubmit
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45 confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
@@ -350,9 +410,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
private func applySnapshot() { private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.overview])
snapshot.appendItems([.overview], toSection: .overview)
for category in viewModel.visibleCategories { for category in viewModel.visibleCategories {
appendTemplates(category, to: &snapshot) appendTemplates(category, to: &snapshot)
} }
snapshot.appendSections([.mode])
snapshot.appendItems([.mode], toSection: .mode)
snapshot.reconfigureItems(snapshot.itemIdentifiers) snapshot.reconfigureItems(snapshot.itemIdentifiers)
dataSource.apply(snapshot, animatingDifferences: true) dataSource.apply(snapshot, animatingDifferences: true)
} }
@@ -385,16 +449,104 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
@objc private func retryTapped() { @objc private func retryTapped() {
Task { await viewModel.loadTemplates(api: api) } 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 { extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath), guard let item = dataSource.itemIdentifier(for: indexPath),
case .template(let category, let template) = item else { return } 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) 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 修图模板卡片,展示模板预览、名称和明确选中态。 /// AI 修图模板卡片,展示模板预览、名称和明确选中态。
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell" static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
@@ -402,6 +554,9 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
private let previewImageView = UIImageView() private let previewImageView = UIImageView()
private let nameLabel = UILabel() private let nameLabel = UILabel()
private let checkView = UIImageView() private let checkView = UIImageView()
private let previewButton = AIRetouchTemplatePreviewButton(type: .system)
var onPreviewTapped: (() -> Void)?
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@@ -421,10 +576,26 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
checkView.tintColor = AIRetouchTemplateStyle.primary checkView.tintColor = AIRetouchTemplateStyle.primary
checkView.backgroundColor = .white checkView.backgroundColor = .white
checkView.layer.cornerRadius = 10 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(previewImageView)
contentView.addSubview(nameLabel) contentView.addSubview(nameLabel)
contentView.addSubview(checkView) contentView.addSubview(checkView)
contentView.addSubview(previewButton)
previewImageView.snp.makeConstraints { make in previewImageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview() make.top.leading.trailing.equalToSuperview()
make.height.equalTo(116) make.height.equalTo(116)
@@ -438,6 +609,11 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
make.top.trailing.equalToSuperview().inset(8) make.top.trailing.equalToSuperview().inset(8)
make.size.equalTo(20) 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) @available(*, unavailable)
@@ -449,6 +625,7 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
super.prepareForReuse() super.prepareForReuse()
previewImageView.kf.cancelDownloadTask() previewImageView.kf.cancelDownloadTask()
previewImageView.image = nil previewImageView.image = nil
onPreviewTapped = nil
} }
/// 更新模板内容与选中状态。 /// 更新模板内容与选中状态。
@@ -478,9 +655,84 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
accessibilityValue = selected ? "已选择" : "未选择" accessibilityValue = selected ? "已选择" : "未选择"
accessibilityTraits = selected ? [.button, .selected] : [.button] accessibilityTraits = selected ? [.button, .selected] : [.button]
} }
@objc private func previewTapped() {
onPreviewTapped?()
}
} }
/// AI 修图模板分组标题,可附带“选填”标签。 /// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。
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 { final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader" static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
@@ -491,12 +743,9 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
super.init(frame: frame) super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
optionalLabel.text = "选填"
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium) optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
optionalLabel.textColor = AIRetouchTemplateStyle.primary
optionalLabel.textAlignment = .center optionalLabel.textAlignment = .center
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1) optionalLabel.layer.cornerRadius = 10
optionalLabel.layer.cornerRadius = 8
optionalLabel.clipsToBounds = true optionalLabel.clipsToBounds = true
addSubview(titleLabel) addSubview(titleLabel)
@@ -508,7 +757,7 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
optionalLabel.snp.makeConstraints { make in optionalLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8) make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalTo(titleLabel) make.centerY.equalTo(titleLabel)
make.width.equalTo(38) make.width.greaterThanOrEqualTo(38)
make.height.equalTo(20) make.height.equalTo(20)
make.trailing.lessThanOrEqualToSuperview().offset(-18) make.trailing.lessThanOrEqualToSuperview().offset(-18)
} }
@@ -519,21 +768,163 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// 更新分组标题与选填标签。 /// 更新分组标题与业务标记。
func apply(title: String, optional: Bool) { fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
titleLabel.text = title titleLabel.text = title
optionalLabel.isHidden = !optional optionalLabel.isHidden = badge == nil
accessibilityLabel = optional ? "\(title),选填" : title 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 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 修图模板页视觉常量。 /// AI 修图模板页视觉常量。
private enum AIRetouchTemplateStyle { private enum AIRetouchTemplateStyle {
static let primary = UIColor(hex: 0x1677FF) static let primary = UIColor(hex: 0x1677FF)
static let pageBackground = UIColor(hex: 0xF7F9FC) static let pageBackground = UIColor(hex: 0xF7F7F7)
static let textPrimary = UIColor(hex: 0x172033) static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7F8A9E) 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 border = UIColor(hex: 0xDCE4EF)
static let danger = UIColor(hex: 0xE53935) static let danger = UIColor(hex: 0xE53935)
} }
@@ -21,6 +21,12 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18]) XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
XCTAssertEqual(viewModel.workflow, .initial(albumId: 8, materialIds: [1, 2, 3, 4])) XCTAssertEqual(viewModel.workflow, .initial(albumId: 8, materialIds: [1, 2, 3, 4]))
XCTAssertEqual(viewModel.selectedPhotoCount, 4)
XCTAssertTrue(viewModel.shouldShowInitialTips)
XCTAssertEqual(
viewModel.initialTipsText,
"Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。"
)
XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover]) XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover])
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11) XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
XCTAssertNil(viewModel.selectedAtmosphereTemplateId) XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
@@ -95,6 +101,8 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined) workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
) )
await refined.loadTemplates(api: api) await refined.loadTemplates(api: api)
XCTAssertEqual(refined.selectedPhotoCount, 1)
XCTAssertFalse(refined.shouldShowInitialTips)
XCTAssertEqual(refined.visibleCategories, [.refined]) XCTAssertEqual(refined.visibleCategories, [.refined])
XCTAssertEqual(refined.selectedRefinedTemplateId, 11) XCTAssertEqual(refined.selectedRefinedTemplateId, 11)
XCTAssertNil(refined.selectedAtmosphereTemplateId) XCTAssertNil(refined.selectedAtmosphereTemplateId)
@@ -296,7 +296,33 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
await waitUntil { confirmButton.isEnabled } await waitUntil { confirmButton.isEnabled }
controller.view.layoutIfNeeded() controller.view.layoutIfNeeded()
let selectionCountLabel = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel"
} as? UILabel
)
let tipsLabel = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsLabel"
} as? UILabel
)
let tipsContainer = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsContainer"
}
)
XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout) XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout)
XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片")
XCTAssertEqual(
tipsLabel.text,
"Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。"
)
XCTAssertFalse(tipsContainer.isHidden)
XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xEAF3FF)
XCTAssertEqual(tipsLabel.textColor.travelAlbumTestHexRGB, 0x385778)
XCTAssertEqual(tipsContainer.layer.cornerRadius, 10)
XCTAssertTrue(tipsContainer.isDescendant(of: collectionView))
XCTAssertFalse(bottomBar.isDescendant(of: collectionView)) XCTAssertFalse(bottomBar.isDescendant(of: collectionView))
XCTAssertEqual(cancelButton.configuration?.title, "取消") XCTAssertEqual(cancelButton.configuration?.title, "取消")
XCTAssertEqual(confirmButton.configuration?.title, "确定") XCTAssertEqual(confirmButton.configuration?.title, "确定")
@@ -327,29 +353,47 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 } await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
controller.view.layoutIfNeeded() controller.view.layoutIfNeeded()
let collectionView = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
} as? UICollectionView
)
let accessibleLabels = controller.view.allAccessibilityLabels() let accessibleLabels = controller.view.allAccessibilityLabels()
XCTAssertTrue(accessibleLabels.contains("原图精修")) XCTAssertTrue(accessibleLabels.contains("原图精修"))
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
XCTAssertTrue(accessibleLabels.contains("封面风格模板")) XCTAssertTrue(accessibleLabels.contains("封面风格模板,赠送 · 不占精修张数"))
collectionView.scrollToItem(
at: IndexPath(item: 0, section: 4),
at: .top,
animated: false
)
controller.view.layoutIfNeeded()
let quotaLabel = try XCTUnwrap( let quotaLabel = try XCTUnwrap(
controller.view.findSubview { controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchRemainingQuotaLabel" $0.accessibilityIdentifier == "travelAlbum.aiRetouchRemainingQuotaLabel"
} as? UILabel } as? UILabel
) )
XCTAssertEqual(quotaLabel.text, "剩余 8 次") XCTAssertEqual(quotaLabel.text, "剩余 8 张")
XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8次修图额度") XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度")
} }
func testAIRetouchTemplateCellExposesSelectedState() { func testAIRetouchTemplateCellExposesSelectedState() {
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero) let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "") let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
var previewTapped = false
cell.onPreviewTapped = { previewTapped = true }
cell.apply(template: template, selected: true) cell.apply(template: template, selected: true)
let previewButton = cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton"
} as? UIButton
previewButton?.sendActions(for: .touchUpInside)
XCTAssertEqual(cell.contentView.layer.borderWidth, 2) XCTAssertEqual(cell.contentView.layer.borderWidth, 2)
XCTAssertTrue(cell.accessibilityTraits.contains(.selected)) XCTAssertTrue(cell.accessibilityTraits.contains(.selected))
XCTAssertEqual(cell.accessibilityValue, "已选择") XCTAssertEqual(cell.accessibilityValue, "已选择")
XCTAssertTrue(previewTapped)
cell.apply(template: template, selected: false) cell.apply(template: template, selected: false)
@@ -358,6 +402,37 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(cell.accessibilityValue, "未选择") XCTAssertEqual(cell.accessibilityValue, "未选择")
} }
func testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws {
let template = TravelAlbumAIRetouchTemplate(
id: 2,
name: "简约留白",
previewURL: ""
)
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let scrollView = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView"
} as? UIScrollView
)
let backButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton"
} as? UIButton
)
XCTAssertEqual(controller.view.backgroundColor, .black)
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "简约留白" })
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "双指缩放查看细节" })
XCTAssertEqual(scrollView.minimumZoomScale, 1)
XCTAssertEqual(scrollView.maximumZoomScale, 4)
XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44)
XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44)
}
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws { func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
UIView.setAnimationsEnabled(false) UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) } defer { UIView.setAnimationsEnabled(true) }
@@ -424,9 +499,29 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
sheet.view.layoutIfNeeded() sheet.view.layoutIfNeeded()
let labels = sheet.view.allAccessibilityLabels() let visibleLabelTexts = sheet.view.allLabels().compactMap { label in
XCTAssertTrue(labels.contains("原图精修")) label.isHidden ? nil : label.text
XCTAssertFalse(labels.contains { $0.contains("氛围感修图") }) }
XCTAssertTrue(visibleLabelTexts.contains("原图精修"))
XCTAssertFalse(visibleLabelTexts.contains { $0.contains("氛围感修图") })
let selectionCountLabel = try XCTUnwrap(
sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel"
} as? UILabel
)
let tipsLabel = try XCTUnwrap(
sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsLabel"
} as? UILabel
)
let tipsContainer = try XCTUnwrap(
sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsContainer"
}
)
XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片")
XCTAssertTrue(tipsContainer.isHidden)
XCTAssertTrue(tipsLabel.isHidden)
confirmButton.sendActions(for: .touchUpInside) confirmButton.sendActions(for: .touchUpInside)
await waitUntil { api.aiReretouchRequests.count == 1 } await waitUntil { api.aiReretouchRequests.count == 1 }
await waitUntil { controller.presentedViewController == nil } await waitUntil { controller.presentedViewController == nil }