feat: 优化AI修图模板选择界面
This commit is contained in:
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user