feat: 优化自动修图设置与模板网格交互
同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。
This commit is contained in:
+17
-25
@@ -2,15 +2,9 @@ import Foundation
|
||||
|
||||
/// 自动修图设置状态,负责加载真实精修模板、单选和配置校验,不直接创建修图任务。
|
||||
final class TravelAlbumAutoRetouchSettingViewModel {
|
||||
/// 设置页当前层级。
|
||||
enum Stage: Sendable, Equatable {
|
||||
case mode
|
||||
case templates
|
||||
}
|
||||
|
||||
let scenicId: Int
|
||||
let startsWithModeSelection: Bool
|
||||
private(set) var stage: Stage
|
||||
/// 是否在模板上方展示修图方式;创建页已选择 AI 时只展示模板。
|
||||
let allowsModeSelection: Bool
|
||||
private(set) var templates: [TravelAlbumAIRetouchTemplate] = []
|
||||
private(set) var selectedTemplateId: Int?
|
||||
private(set) var isEnabled: Bool
|
||||
@@ -23,15 +17,24 @@ final class TravelAlbumAutoRetouchSettingViewModel {
|
||||
init(
|
||||
scenicId: Int,
|
||||
configuration: TravelAlbumAutoRetouchConfiguration,
|
||||
startsWithModeSelection: Bool
|
||||
allowsModeSelection: Bool
|
||||
) {
|
||||
self.scenicId = scenicId
|
||||
self.startsWithModeSelection = startsWithModeSelection
|
||||
self.stage = startsWithModeSelection ? .mode : .templates
|
||||
self.isEnabled = configuration.enabled
|
||||
self.allowsModeSelection = allowsModeSelection
|
||||
self.isEnabled = configuration.enabled || !allowsModeSelection
|
||||
self.selectedTemplateId = configuration.refinedTemplateId
|
||||
}
|
||||
|
||||
/// 关闭自动修图不依赖模板加载;开启时必须选择有效模板。
|
||||
var canConfirm: Bool {
|
||||
!isEnabled || (!isLoading && pendingConfiguration != nil)
|
||||
}
|
||||
|
||||
/// 当前草稿模板名称,用于固定底部的选择摘要。
|
||||
var selectedTemplateName: String? {
|
||||
templates.first { $0.id == selectedTemplateId }?.name
|
||||
}
|
||||
|
||||
/// 当前可提交配置;启用状态必须已经选择服务端模板。
|
||||
var pendingConfiguration: TravelAlbumAutoRetouchConfiguration? {
|
||||
if !isEnabled { return .disabled }
|
||||
@@ -75,14 +78,10 @@ final class TravelAlbumAutoRetouchSettingViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择一级修图方式。
|
||||
/// 切换修图方式,保留本次草稿模板以便再次开启;关闭配置提交时仍不携带模板。
|
||||
func selectMode(enabled: Bool) {
|
||||
guard allowsModeSelection else { return }
|
||||
isEnabled = enabled
|
||||
if enabled {
|
||||
stage = .templates
|
||||
} else {
|
||||
selectedTemplateId = nil
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
@@ -94,12 +93,5 @@ final class TravelAlbumAutoRetouchSettingViewModel {
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 从模板列表返回修图方式层级。
|
||||
func returnToModeSelection() {
|
||||
guard startsWithModeSelection else { return }
|
||||
stage = .mode
|
||||
notify()
|
||||
}
|
||||
|
||||
private func notify() { onStateChange?() }
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: AppStore.shared.session.currentScenicId,
|
||||
configuration: autoRetouchConfiguration,
|
||||
startsWithModeSelection: false
|
||||
allowsModeSelection: false
|
||||
)
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||
viewModel: settingViewModel,
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 相册自动 AI 修图设置 Sheet,使用服务端精修模板并保持单选。
|
||||
/// 相册自动修图设置:在同一页切换修图方式、展开模板并确认草稿。
|
||||
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController {
|
||||
private enum Item: Hashable {
|
||||
case mode(Bool)
|
||||
case template(TravelAlbumAIRetouchTemplate)
|
||||
}
|
||||
|
||||
/// 确认草稿后通知调用方保存相册配置。
|
||||
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
|
||||
|
||||
private let viewModel: TravelAlbumAutoRetouchSettingViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, Item>!
|
||||
private let statusContainer = UIView()
|
||||
private let headerStack = UIStackView()
|
||||
private let modeStack = UIStackView()
|
||||
private let originalOption = AutoRetouchModeControl(title: "不修图", detail: "保留相机原始照片", symbol: "photo")
|
||||
private let aiOption = AutoRetouchModeControl(title: "AI 修图", detail: "上传后自动套用模板", symbol: "wand.and.stars")
|
||||
private let contentRegion = UIView()
|
||||
private let templateHeader = UIStackView()
|
||||
private lazy var templateCollectionView = UICollectionView(frame: .zero, collectionViewLayout: makeTemplateLayout())
|
||||
private var templateDataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>!
|
||||
private let originalHint = UIStackView()
|
||||
private let statusStack = UIStackView()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let statusLabel = UILabel()
|
||||
private let retryButton = UIButton(type: .system)
|
||||
private let footer = UIView()
|
||||
private let selectionLabel = UILabel()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private var lastTemplatesVisible: Bool?
|
||||
|
||||
/// 创建设置 Sheet。
|
||||
init(
|
||||
viewModel: TravelAlbumAutoRetouchSettingViewModel,
|
||||
api: any TravelAlbumServing
|
||||
) {
|
||||
/// 创建设置 Sheet;方式选择与模板区域共用同一份配置草稿。
|
||||
init(viewModel: TravelAlbumAutoRetouchSettingViewModel, api: any TravelAlbumServing) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .pageSheet
|
||||
if let sheetPresentationController {
|
||||
sheetPresentationController.detents = [.large()]
|
||||
sheetPresentationController.selectedDetentIdentifier = .large
|
||||
sheetPresentationController.prefersGrabberVisible = true
|
||||
sheetPresentationController.preferredCornerRadius = 22
|
||||
if let sheet = sheetPresentationController {
|
||||
sheet.detents = [.large()]
|
||||
sheet.selectedDetentIdentifier = .large
|
||||
sheet.prefersGrabberVisible = true
|
||||
sheet.preferredCornerRadius = 24
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,100 +47,167 @@ final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = .white
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
view.backgroundColor = UIColor(hex: 0xF6F8FC)
|
||||
titleLabel.text = viewModel.allowsModeSelection ? "选择修图方式" : "选择修图模板"
|
||||
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.accessibilityTraits = .header
|
||||
subtitleLabel.text = "设置仅对后续上传的照片生效"
|
||||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||
subtitleLabel.textColor = AppColor.textSecondary
|
||||
subtitleLabel.textAlignment = .center
|
||||
subtitleLabel.numberOfLines = 0
|
||||
headerStack.axis = .vertical
|
||||
headerStack.spacing = 8
|
||||
headerStack.addArrangedSubview(titleLabel)
|
||||
headerStack.addArrangedSubview(subtitleLabel)
|
||||
headerStack.setCustomSpacing(20, after: subtitleLabel)
|
||||
modeStack.axis = .horizontal
|
||||
modeStack.distribution = .fillEqually
|
||||
modeStack.spacing = 12
|
||||
modeStack.accessibilityIdentifier = "travelAlbum.autoRetouchModeOptions"
|
||||
originalOption.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalOption"
|
||||
aiOption.accessibilityIdentifier = "travelAlbum.autoRetouchAIOption"
|
||||
modeStack.addArrangedSubview(originalOption)
|
||||
modeStack.addArrangedSubview(aiOption)
|
||||
headerStack.addArrangedSubview(modeStack)
|
||||
modeStack.isHidden = !viewModel.allowsModeSelection
|
||||
|
||||
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
backButton.tintColor = AppColor.textPrimary
|
||||
backButton.accessibilityLabel = "返回修图方式"
|
||||
let templateTitle = UILabel()
|
||||
templateTitle.text = viewModel.allowsModeSelection ? "选择修图模板" : "精修模板"
|
||||
templateTitle.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
templateTitle.textColor = AppColor.textPrimary
|
||||
templateTitle.accessibilityTraits = .header
|
||||
let templateTips = UILabel()
|
||||
templateTips.text = "单选模板 · 点击预览查看前后效果"
|
||||
templateTips.font = .systemFont(ofSize: 12)
|
||||
templateTips.textColor = AppColor.textSecondary
|
||||
templateHeader.axis = .vertical
|
||||
templateHeader.spacing = 5
|
||||
templateHeader.addArrangedSubview(templateTitle)
|
||||
templateHeader.addArrangedSubview(templateTips)
|
||||
|
||||
tableView.backgroundColor = .white
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = 116
|
||||
tableView.delegate = self
|
||||
tableView.register(AutoRetouchOptionCell.self, forCellReuseIdentifier: AutoRetouchOptionCell.reuseIdentifier)
|
||||
templateCollectionView.backgroundColor = .clear
|
||||
templateCollectionView.alwaysBounceVertical = true
|
||||
templateCollectionView.accessibilityIdentifier = "travelAlbum.autoRetouchTemplateCollection"
|
||||
templateCollectionView.delegate = self
|
||||
templateCollectionView.register(TravelAlbumAIRetouchTemplateCell.self,
|
||||
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier)
|
||||
configureDataSource()
|
||||
|
||||
let hintIcon = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
|
||||
hintIcon.tintColor = AppColor.primary.withAlphaComponent(0.6)
|
||||
hintIcon.contentMode = .scaleAspectFit
|
||||
let hintTitle = UILabel()
|
||||
hintTitle.text = "保留原图,直接上传"
|
||||
hintTitle.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
hintTitle.textColor = AppColor.textPrimary
|
||||
let hintDetail = UILabel()
|
||||
hintDetail.text = "选择上方 AI 修图,即可在这里挑选模板"
|
||||
hintDetail.font = .systemFont(ofSize: 13)
|
||||
hintDetail.textColor = AppColor.textSecondary
|
||||
hintDetail.numberOfLines = 2
|
||||
hintDetail.textAlignment = .center
|
||||
originalHint.axis = .vertical
|
||||
originalHint.alignment = .center
|
||||
originalHint.spacing = 12
|
||||
originalHint.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalHint"
|
||||
originalHint.addArrangedSubview(hintIcon)
|
||||
originalHint.addArrangedSubview(hintTitle)
|
||||
originalHint.addArrangedSubview(hintDetail)
|
||||
hintIcon.snp.makeConstraints { $0.size.equalTo(48) }
|
||||
|
||||
statusStack.axis = .vertical
|
||||
statusStack.alignment = .center
|
||||
statusStack.spacing = 12
|
||||
statusStack.addArrangedSubview(activityIndicator)
|
||||
statusStack.addArrangedSubview(statusLabel)
|
||||
statusStack.addArrangedSubview(retryButton)
|
||||
activityIndicator.color = AppColor.primary
|
||||
statusLabel.font = .systemFont(ofSize: 14)
|
||||
statusLabel.textColor = AppColor.textSecondary
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.numberOfLines = 0
|
||||
retryButton.setTitle("重试", for: .normal)
|
||||
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
activityIndicator.color = AppColor.primary
|
||||
retryButton.setTitle("重新加载", for: .normal)
|
||||
retryButton.tintColor = AppColor.primary
|
||||
retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
retryButton.snp.makeConstraints { $0.height.equalTo(44) }
|
||||
|
||||
footer.backgroundColor = .white
|
||||
footer.layer.shadowColor = UIColor(hex: 0x182B49).cgColor
|
||||
footer.layer.shadowOpacity = 0.04
|
||||
footer.layer.shadowRadius = 12
|
||||
footer.layer.shadowOffset = CGSize(width: 0, height: -3)
|
||||
selectionLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
selectionLabel.textColor = AppColor.textSecondary
|
||||
selectionLabel.lineBreakMode = .byTruncatingTail
|
||||
selectionLabel.accessibilityIdentifier = "travelAlbum.autoRetouchSelectionSummary"
|
||||
configureAction(cancelButton, title: "取消", filled: false)
|
||||
configureAction(confirmButton, title: "确定", filled: true)
|
||||
confirmButton.accessibilityIdentifier = "travelAlbum.autoRetouchConfirmButton"
|
||||
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(subtitleLabel)
|
||||
view.addSubview(backButton)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(statusContainer)
|
||||
statusContainer.addSubview(activityIndicator)
|
||||
statusContainer.addSubview(statusLabel)
|
||||
statusContainer.addSubview(retryButton)
|
||||
view.addSubview(cancelButton)
|
||||
view.addSubview(confirmButton)
|
||||
view.addSubview(headerStack)
|
||||
view.addSubview(contentRegion)
|
||||
contentRegion.addSubview(templateHeader)
|
||||
contentRegion.addSubview(templateCollectionView)
|
||||
contentRegion.addSubview(originalHint)
|
||||
contentRegion.addSubview(statusStack)
|
||||
view.addSubview(footer)
|
||||
footer.addSubview(selectionLabel)
|
||||
footer.addSubview(cancelButton)
|
||||
footer.addSubview(confirmButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(56)
|
||||
headerStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(28)
|
||||
contentRegion.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerStack.snp.bottom).offset(22)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(footer.snp.top).offset(-8)
|
||||
}
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.size.equalTo(36)
|
||||
templateHeader.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
templateCollectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(templateHeader.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
originalHint.snp.makeConstraints { make in
|
||||
make.centerY.equalToSuperview().offset(-12)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
statusStack.snp.makeConstraints { make in
|
||||
make.center.equalTo(templateCollectionView)
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
footer.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
selectionLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(14)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(selectionLabel.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.height.equalTo(50)
|
||||
make.width.equalTo(96)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
make.height.equalTo(52)
|
||||
make.width.equalTo(confirmButton)
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.top.bottom.width.equalTo(cancelButton)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(14)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(cancelButton.snp.top).offset(-12)
|
||||
}
|
||||
statusContainer.snp.makeConstraints { $0.edges.equalTo(tableView) }
|
||||
activityIndicator.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-30)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(activityIndicator.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(36)
|
||||
}
|
||||
retryButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusLabel.snp.bottom).offset(10)
|
||||
make.centerX.equalToSuperview()
|
||||
make.height.equalTo(36)
|
||||
make.top.bottom.equalTo(cancelButton)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
originalOption.addTarget(self, action: #selector(originalTapped), for: .touchUpInside)
|
||||
aiOption.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
@@ -153,205 +221,172 @@ final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController
|
||||
}
|
||||
|
||||
private func configureDataSource() {
|
||||
dataSource = UITableViewDiffableDataSource<Int, Item>(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||
templateDataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>(
|
||||
collectionView: templateCollectionView
|
||||
) { [weak self] collectionView, indexPath, template in
|
||||
guard let self else { return nil }
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: AutoRetouchOptionCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! AutoRetouchOptionCell
|
||||
switch item {
|
||||
case .mode(let enabled):
|
||||
cell.apply(
|
||||
title: enabled ? "AI 修图" : "不修图",
|
||||
detail: enabled ? "选择后需再选一个修图模板" : "保留原图",
|
||||
imageURL: nil,
|
||||
systemImage: enabled ? "wand.and.stars" : "photo",
|
||||
selected: self.viewModel.isEnabled == enabled
|
||||
)
|
||||
case .template(let template):
|
||||
cell.apply(
|
||||
title: template.name,
|
||||
detail: "",
|
||||
imageURL: template.previewURL,
|
||||
systemImage: nil,
|
||||
selected: self.viewModel.selectedTemplateId == template.id
|
||||
)
|
||||
}
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier, for: indexPath
|
||||
) as! TravelAlbumAIRetouchTemplateCell
|
||||
cell.apply(template: template, selected: self.viewModel.selectedTemplateId == template.id)
|
||||
cell.onPreviewTapped = { [weak self] in self?.showPreview(for: template) }
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTemplateLayout() -> UICollectionViewCompositionalLayout {
|
||||
let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)
|
||||
))
|
||||
let group = NSCollectionLayoutGroup.horizontal(
|
||||
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(156)),
|
||||
subitem: item, count: 3
|
||||
)
|
||||
group.interItemSpacing = .fixed(12)
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.interGroupSpacing = 12
|
||||
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 16, bottom: 12, trailing: 16)
|
||||
return UICollectionViewCompositionalLayout(section: section)
|
||||
}
|
||||
|
||||
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
|
||||
guard presentedViewController == nil else { return }
|
||||
guard let content = template.comparisonContent else { showToast("暂无对比预览"); return }
|
||||
present(BeforeAfterComparisonViewController(viewModel: BeforeAfterComparisonViewModel(content: content)), animated: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
let isMode = viewModel.stage == .mode
|
||||
titleLabel.text = isMode ? "选择修图方式" : "选择修图模板"
|
||||
subtitleLabel.text = isMode
|
||||
? "照片上传前可选择保留原图,或使用 AI 自动修图"
|
||||
: "缩略图为模板实际效果,后续上传的照片将自动套用"
|
||||
backButton.isHidden = isMode || !viewModel.startsWithModeSelection
|
||||
confirmButton.isEnabled = viewModel.pendingConfiguration != nil && !viewModel.isLoading
|
||||
confirmButton.alpha = confirmButton.isEnabled ? 1 : 0.45
|
||||
|
||||
statusContainer.isHidden = !viewModel.isLoading && viewModel.errorMessage == nil
|
||||
let showsTemplates = viewModel.isEnabled
|
||||
originalOption.apply(selected: !showsTemplates)
|
||||
aiOption.apply(selected: showsTemplates)
|
||||
confirmButton.isEnabled = viewModel.canConfirm
|
||||
confirmButton.alpha = viewModel.canConfirm ? 1 : 0.4
|
||||
selectionLabel.text = !showsTemplates ? "将保留原图,不进行 AI 修图"
|
||||
: viewModel.selectedTemplateName.map { "已选择:\($0)" } ?? "请选择一个修图模板"
|
||||
selectionLabel.textColor = showsTemplates && viewModel.selectedTemplateName != nil ? AppColor.primary : AppColor.textSecondary
|
||||
let updateVisibility = {
|
||||
self.originalHint.isHidden = showsTemplates
|
||||
self.templateHeader.isHidden = !showsTemplates
|
||||
self.templateCollectionView.isHidden = !showsTemplates || self.viewModel.isLoading || self.viewModel.errorMessage != nil
|
||||
self.statusStack.isHidden = !showsTemplates || (!self.viewModel.isLoading && self.viewModel.errorMessage == nil)
|
||||
}
|
||||
if let previous = lastTemplatesVisible, previous != showsTemplates,
|
||||
view.window != nil, !UIAccessibility.isReduceMotionEnabled {
|
||||
UIView.transition(with: contentRegion, duration: 0.2, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: updateVisibility)
|
||||
} else {
|
||||
updateVisibility()
|
||||
}
|
||||
lastTemplatesVisible = showsTemplates
|
||||
if viewModel.isLoading {
|
||||
activityIndicator.startAnimating()
|
||||
statusLabel.text = "正在加载修图模板…"
|
||||
retryButton.isHidden = true
|
||||
} else {
|
||||
activityIndicator.stopAnimating()
|
||||
statusLabel.text = viewModel.errorMessage
|
||||
retryButton.isHidden = viewModel.errorMessage == nil
|
||||
}
|
||||
|
||||
let previousItems = Set(dataSource.snapshot().itemIdentifiers)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
|
||||
activityIndicator.isHidden = !viewModel.isLoading
|
||||
statusLabel.text = viewModel.isLoading ? "正在加载修图模板…" : viewModel.errorMessage
|
||||
retryButton.isHidden = viewModel.isLoading
|
||||
let previousTemplates = Set(templateDataSource.snapshot().itemIdentifiers)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumAIRetouchTemplate>()
|
||||
snapshot.appendSections([0])
|
||||
let items: [Item]
|
||||
if isMode {
|
||||
items = [.mode(false), .mode(true)]
|
||||
} else {
|
||||
items = viewModel.templates.map(Item.template)
|
||||
}
|
||||
snapshot.appendItems(items)
|
||||
snapshot.reconfigureItems(items.filter(previousItems.contains))
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
snapshot.appendItems(viewModel.templates)
|
||||
snapshot.reconfigureItems(viewModel.templates.filter(previousTemplates.contains))
|
||||
templateDataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
|
||||
private func configureAction(_ button: UIButton, title: String, filled: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
button.layer.cornerRadius = 12
|
||||
if filled {
|
||||
button.backgroundColor = AppColor.primary
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
} else {
|
||||
button.backgroundColor = UIColor(hex: 0xF4F5F7)
|
||||
button.setTitleColor(AppColor.textSecondary, for: .normal)
|
||||
}
|
||||
button.layer.cornerRadius = 14
|
||||
button.backgroundColor = filled ? AppColor.primary : .white
|
||||
button.setTitleColor(filled ? .white : AppColor.textSecondary, for: .normal)
|
||||
button.layer.borderWidth = filled ? 0 : 1
|
||||
button.layer.borderColor = UIColor(hex: 0xDFE5EF).cgColor
|
||||
}
|
||||
|
||||
@objc private func originalTapped() { viewModel.selectMode(enabled: false) }
|
||||
@objc private func aiTapped() { viewModel.selectMode(enabled: true) }
|
||||
@objc private func cancelTapped() { dismiss(animated: true) }
|
||||
@objc private func backTapped() { viewModel.returnToModeSelection() }
|
||||
@objc private func retryTapped() { Task { await viewModel.loadTemplates(api: api) } }
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard let configuration = viewModel.pendingConfiguration else { return }
|
||||
guard viewModel.canConfirm, let configuration = viewModel.pendingConfiguration else { return }
|
||||
let completion = onConfirm
|
||||
dismiss(animated: true) { completion?(configuration) }
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAutoRetouchSettingSheetViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch item {
|
||||
case .mode(let enabled): viewModel.selectMode(enabled: enabled)
|
||||
case .template(let template): viewModel.selectTemplate(id: template.id)
|
||||
}
|
||||
extension TravelAlbumAutoRetouchSettingSheetViewController: UICollectionViewDelegate {
|
||||
/// 点击模板更新草稿;预览按钮独立处理,不改变选择。
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let template = templateDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
viewModel.selectTemplate(id: template.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动修图方式或模板列表单元,提供效果图、说明和明确单选状态。
|
||||
private final class AutoRetouchOptionCell: UITableViewCell {
|
||||
static let reuseIdentifier = "AutoRetouchOptionCell"
|
||||
private let card = UIView()
|
||||
private let previewImageView = UIImageView()
|
||||
/// 修图方式单选卡片,使用图标、边框和勾选共同传达当前选择。
|
||||
private final class AutoRetouchModeControl: UIControl {
|
||||
private let iconContainer = UIView()
|
||||
private let iconView = UIImageView()
|
||||
private let checkView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let selectionImageView = UIImageView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
card.layer.cornerRadius = 12
|
||||
card.layer.borderWidth = 1
|
||||
previewImageView.contentMode = .scaleAspectFill
|
||||
previewImageView.clipsToBounds = true
|
||||
previewImageView.layer.cornerRadius = 8
|
||||
/// 创建可点击的方式卡片;所有内容由整个控件统一响应触摸。
|
||||
init(title: String, detail: String, symbol: String) {
|
||||
super.init(frame: .zero)
|
||||
layer.cornerRadius = 16
|
||||
layer.borderWidth = 1
|
||||
iconContainer.layer.cornerRadius = 10
|
||||
iconView.image = UIImage(systemName: symbol)
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
detailLabel.font = .systemFont(ofSize: 13)
|
||||
let detailLabel = UILabel()
|
||||
detailLabel.text = detail
|
||||
detailLabel.font = .systemFont(ofSize: 12)
|
||||
detailLabel.textColor = AppColor.textSecondary
|
||||
detailLabel.numberOfLines = 2
|
||||
selectionImageView.contentMode = .scaleAspectFit
|
||||
|
||||
contentView.addSubview(card)
|
||||
card.addSubview(previewImageView)
|
||||
card.addSubview(titleLabel)
|
||||
card.addSubview(detailLabel)
|
||||
card.addSubview(selectionImageView)
|
||||
card.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(6)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
checkView.contentMode = .scaleAspectFit
|
||||
[iconContainer, iconView, checkView, titleLabel, detailLabel].forEach {
|
||||
$0.isUserInteractionEnabled = false
|
||||
addSubview($0)
|
||||
}
|
||||
previewImageView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(12)
|
||||
make.top.bottom.equalToSuperview().inset(10)
|
||||
make.width.equalTo(previewImageView.snp.height)
|
||||
iconContainer.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(12)
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
selectionImageView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(24)
|
||||
iconView.snp.makeConstraints { $0.edges.equalTo(iconContainer).inset(7) }
|
||||
checkView.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(12)
|
||||
make.size.equalTo(22)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(previewImageView.snp.trailing).offset(14)
|
||||
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-12)
|
||||
make.bottom.equalTo(card.snp.centerY).offset(-2)
|
||||
make.top.equalTo(iconContainer.snp.bottom).offset(10)
|
||||
make.leading.trailing.equalToSuperview().inset(12)
|
||||
}
|
||||
detailLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
make.top.equalTo(card.snp.centerY).offset(4)
|
||||
make.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
isAccessibilityElement = true
|
||||
accessibilityLabel = "\(title),\(detail)"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
previewImageView.kf.cancelDownloadTask()
|
||||
previewImageView.image = nil
|
||||
}
|
||||
|
||||
func apply(
|
||||
title: String,
|
||||
detail: String,
|
||||
imageURL: String?,
|
||||
systemImage: String?,
|
||||
selected: Bool
|
||||
) {
|
||||
titleLabel.text = title
|
||||
let normalizedDetail = detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
detailLabel.text = normalizedDetail
|
||||
detailLabel.isHidden = normalizedDetail.isEmpty
|
||||
titleLabel.snp.remakeConstraints { make in
|
||||
make.leading.equalTo(previewImageView.snp.trailing).offset(14)
|
||||
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-12)
|
||||
if normalizedDetail.isEmpty {
|
||||
make.centerY.equalToSuperview()
|
||||
} else {
|
||||
make.bottom.equalTo(card.snp.centerY).offset(-2)
|
||||
}
|
||||
}
|
||||
if let imageURL, let url = URL(string: imageURL), !imageURL.isEmpty {
|
||||
previewImageView.contentMode = .scaleAspectFill
|
||||
previewImageView.backgroundColor = .clear
|
||||
previewImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
|
||||
} else {
|
||||
previewImageView.image = systemImage.flatMap(UIImage.init(systemName:))
|
||||
previewImageView.contentMode = .center
|
||||
previewImageView.tintColor = AppColor.primary
|
||||
previewImageView.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
|
||||
}
|
||||
card.backgroundColor = selected ? AppColor.primary.withAlphaComponent(0.07) : .white
|
||||
card.layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
|
||||
selectionImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
selectionImageView.tintColor = selected ? AppColor.primary : AppColor.textTertiary
|
||||
accessibilityLabel = normalizedDetail.isEmpty ? title : "\(title),\(normalizedDetail)"
|
||||
/// 更新卡片背景与单选语义。
|
||||
func apply(selected: Bool) {
|
||||
isSelected = selected
|
||||
backgroundColor = selected ? AppColor.primaryLight : .white
|
||||
layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xDFE5EF)).cgColor
|
||||
layer.borderWidth = selected ? 1.5 : 1
|
||||
iconContainer.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEFF2F7)
|
||||
iconView.tintColor = selected ? .white : AppColor.textSecondary
|
||||
titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
|
||||
checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
checkView.tintColor = selected ? AppColor.primary : UIColor(hex: 0xC7CFDC)
|
||||
accessibilityValue = selected ? "已选择" : "未选择"
|
||||
isAccessibilityElement = true
|
||||
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,7 +787,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: AppStore.shared.session.currentScenicId,
|
||||
configuration: viewModel.autoRetouchConfiguration,
|
||||
startsWithModeSelection: true
|
||||
allowsModeSelection: true
|
||||
)
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||
viewModel: settingViewModel,
|
||||
|
||||
@@ -955,6 +955,213 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 2)
|
||||
}
|
||||
|
||||
func testAutoRetouchGridUsesThreeColumnsAndKeepsFooterAndSelectionWhileScrolling() async throws {
|
||||
let fixture = try await makeAutoRetouchSheet(templateCount: 19)
|
||||
let controller = fixture.controller
|
||||
let collection = fixture.collection
|
||||
let confirm = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchConfirmButton"
|
||||
} as? UIButton)
|
||||
XCTAssertTrue(collection.collectionViewLayout is UICollectionViewCompositionalLayout)
|
||||
XCTAssertFalse(confirm.isDescendant(of: collection))
|
||||
|
||||
for width: CGFloat in [375, 390, 430] {
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: width, height: 667)
|
||||
controller.view.layoutIfNeeded()
|
||||
collection.layoutIfNeeded()
|
||||
let frames = try (0 ..< 4).map { index in
|
||||
try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: index, section: 0))).frame
|
||||
}
|
||||
XCTAssertEqual(frames[0].minX, 16, accuracy: 0.5)
|
||||
XCTAssertEqual(frames[0].width, (width - 56) / 3, accuracy: 0.5)
|
||||
XCTAssertEqual(frames[0].height, 156)
|
||||
XCTAssertEqual(frames[1].minY, frames[0].minY)
|
||||
XCTAssertEqual(frames[2].minY, frames[0].minY)
|
||||
XCTAssertEqual(frames[1].minX - frames[0].maxX, 12, accuracy: 0.5)
|
||||
XCTAssertEqual(frames[2].minX - frames[1].maxX, 12, accuracy: 0.5)
|
||||
XCTAssertEqual(frames[3].minX, frames[0].minX)
|
||||
XCTAssertEqual(frames[3].minY - frames[0].maxY, 12, accuracy: 0.5)
|
||||
XCTAssertLessThanOrEqual(collection.contentSize.width, collection.bounds.width)
|
||||
}
|
||||
|
||||
let footerFrame = confirm.convert(confirm.bounds, to: controller.view)
|
||||
let last = IndexPath(item: 18, section: 0)
|
||||
collection.scrollToItem(at: last, at: .bottom, animated: false)
|
||||
collection.layoutIfNeeded()
|
||||
let lastCell = try XCTUnwrap(collection.cellForItem(at: last) as? TravelAlbumAIRetouchTemplateCell)
|
||||
XCTAssertEqual(lastCell.frame.minX, 16, accuracy: 0.5)
|
||||
XCTAssertGreaterThan(collection.contentOffset.y, 0)
|
||||
let scrollOffset = collection.contentOffset
|
||||
collection.delegate?.collectionView?(collection, didSelectItemAt: last)
|
||||
await waitUntil { lastCell.accessibilityValue == "已选择" }
|
||||
XCTAssertEqual(fixture.viewModel.pendingConfiguration?.refinedTemplateId, 19)
|
||||
XCTAssertEqual(collection.contentOffset, scrollOffset)
|
||||
XCTAssertEqual(confirm.convert(confirm.bounds, to: controller.view), footerFrame)
|
||||
XCTAssertLessThanOrEqual(collection.convert(collection.bounds, to: controller.view).maxY, footerFrame.minY)
|
||||
XCTAssertTrue(confirm.isEnabled)
|
||||
collection.delegate?.collectionView?(collection, didSelectItemAt: last)
|
||||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 19)
|
||||
|
||||
collection.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: false)
|
||||
collection.layoutIfNeeded()
|
||||
let first = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: 0, section: 0)))
|
||||
XCTAssertEqual(first.accessibilityValue, "未选择")
|
||||
collection.scrollToItem(at: last, at: .bottom, animated: false)
|
||||
collection.layoutIfNeeded()
|
||||
XCTAssertEqual(collection.cellForItem(at: last)?.accessibilityValue, "已选择")
|
||||
}
|
||||
|
||||
func testAutoRetouchModeSelectionExpandsTemplatesInlineAndKeepsDraftWhenToggling() async throws {
|
||||
let fixture = try await makeAutoRetouchSheet(templateCount: 19, allowsModeSelection: true, configuration: .disabled)
|
||||
let controller = fixture.controller
|
||||
let original = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchOriginalOption"
|
||||
} as? UIControl)
|
||||
let ai = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchAIOption"
|
||||
} as? UIControl)
|
||||
let confirm = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchConfirmButton"
|
||||
} as? UIButton)
|
||||
let summary = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchSelectionSummary"
|
||||
} as? UILabel)
|
||||
XCTAssertTrue(original.isSelected)
|
||||
XCTAssertTrue(fixture.collection.isHidden)
|
||||
XCTAssertTrue(confirm.isEnabled)
|
||||
let modeFrame = ai.convert(ai.bounds, to: controller.view)
|
||||
ai.sendActions(for: .touchUpInside)
|
||||
await waitUntil { !fixture.collection.isHidden }
|
||||
controller.view.layoutIfNeeded()
|
||||
fixture.collection.layoutIfNeeded()
|
||||
XCTAssertTrue(ai.isSelected)
|
||||
XCTAssertFalse(original.isSelected)
|
||||
XCTAssertFalse(confirm.isEnabled)
|
||||
XCTAssertNil(controller.presentedViewController)
|
||||
XCTAssertEqual(ai.convert(ai.bounds, to: controller.view), modeFrame)
|
||||
XCTAssertGreaterThan(fixture.collection.convert(fixture.collection.bounds, to: controller.view).minY, modeFrame.maxY)
|
||||
fixture.viewModel.selectTemplate(id: 4)
|
||||
await waitUntil { summary.text == "已选择:模板4" }
|
||||
let last = IndexPath(item: 18, section: 0)
|
||||
fixture.collection.scrollToItem(at: last, at: .bottom, animated: false)
|
||||
fixture.collection.layoutIfNeeded()
|
||||
let offset = fixture.collection.contentOffset
|
||||
original.sendActions(for: .touchUpInside)
|
||||
await waitUntil { fixture.collection.isHidden }
|
||||
XCTAssertTrue(confirm.isEnabled)
|
||||
XCTAssertEqual(fixture.viewModel.pendingConfiguration, .disabled)
|
||||
ai.sendActions(for: .touchUpInside)
|
||||
await waitUntil { !fixture.collection.isHidden }
|
||||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 4)
|
||||
XCTAssertEqual(fixture.collection.contentOffset, offset)
|
||||
XCTAssertEqual(summary.text, "已选择:模板4")
|
||||
XCTAssertTrue(confirm.isEnabled)
|
||||
fixture.collection.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: false)
|
||||
controller.view.layoutIfNeeded()
|
||||
fixture.collection.layoutIfNeeded()
|
||||
let image = UIGraphicsImageRenderer(bounds: controller.view.bounds).image { context in
|
||||
controller.view.layer.render(in: context.cgContext)
|
||||
}
|
||||
let attachment = XCTAttachment(image: image)
|
||||
attachment.name = "自动修图-同页模板选择"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
func testAutoRetouchPreviewDoesNotSelectTemplateAndReturnsToSameScrollPosition() async throws {
|
||||
let fixture = try await makeAutoRetouchSheet(templateCount: 19)
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
let host = UIViewController()
|
||||
window.rootViewController = host
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
host.present(fixture.controller, animated: false) { continuation.resume() }
|
||||
}
|
||||
fixture.controller.view.layoutIfNeeded()
|
||||
let collection = fixture.collection
|
||||
let last = IndexPath(item: 18, section: 0)
|
||||
collection.scrollToItem(at: last, at: .bottom, animated: false)
|
||||
collection.layoutIfNeeded()
|
||||
let offset = collection.contentOffset
|
||||
let cell = try XCTUnwrap(collection.cellForItem(at: last))
|
||||
let preview = try XCTUnwrap(cell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton"
|
||||
} as? UIButton)
|
||||
preview.sendActions(for: .touchUpInside)
|
||||
await waitUntil { fixture.controller.presentedViewController is BeforeAfterComparisonViewController }
|
||||
let comparison = try XCTUnwrap(fixture.controller.presentedViewController as? BeforeAfterComparisonViewController)
|
||||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
|
||||
XCTAssertEqual(fixture.viewModel.pendingConfiguration?.refinedTemplateId, 1)
|
||||
let returned = expectation(description: "Return from comparison")
|
||||
// 等待展示动画完成,再模拟预览返回。
|
||||
if let transition = comparison.transitionCoordinator {
|
||||
transition.animate(alongsideTransition: nil) { _ in
|
||||
comparison.dismiss(animated: false) { returned.fulfill() }
|
||||
}
|
||||
} else {
|
||||
comparison.dismiss(animated: false) { returned.fulfill() }
|
||||
}
|
||||
await fulfillment(of: [returned], timeout: 3)
|
||||
XCTAssertNil(fixture.controller.presentedViewController)
|
||||
XCTAssertEqual(collection.contentOffset, offset)
|
||||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
|
||||
}
|
||||
|
||||
func testAutoRetouchMissingComparisonKeepsSelectionAndShowsMessage() async throws {
|
||||
let fixture = try await makeAutoRetouchSheet(templateCount: 2, hasComparison: false)
|
||||
let cell = try XCTUnwrap(fixture.collection.cellForItem(at: IndexPath(item: 1, section: 0)))
|
||||
let preview = try XCTUnwrap(cell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton"
|
||||
} as? UIButton)
|
||||
preview.sendActions(for: .touchUpInside)
|
||||
XCTAssertNil(fixture.controller.presentedViewController)
|
||||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
|
||||
XCTAssertTrue(fixture.controller.view.allLabels().contains { $0.text == "暂无对比预览" })
|
||||
}
|
||||
|
||||
private func makeAutoRetouchSheet(
|
||||
templateCount: Int,
|
||||
allowsModeSelection: Bool = false,
|
||||
hasComparison: Bool = true,
|
||||
configuration: TravelAlbumAutoRetouchConfiguration = TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 1)
|
||||
) async throws -> (
|
||||
controller: TravelAlbumAutoRetouchSettingSheetViewController,
|
||||
viewModel: TravelAlbumAutoRetouchSettingViewModel,
|
||||
collection: UICollectionView
|
||||
) {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let imageURL = FileManager.default.temporaryDirectory.appendingPathComponent("auto-retouch-\(UUID().uuidString).png")
|
||||
let imageData = try XCTUnwrap(UIImage(named: "ai_retouch_template_placeholder")?.pngData())
|
||||
try imageData.write(to: imageURL)
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: imageURL) }
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: (1 ... templateCount).map {
|
||||
TravelAlbumAIRetouchTemplate(
|
||||
id: $0, name: "模板\($0)", previewURL: "",
|
||||
beforeURL: hasComparison ? imageURL.absoluteString : "",
|
||||
afterURL: hasComparison ? imageURL.absoluteString : ""
|
||||
)
|
||||
}
|
||||
)
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: 18,
|
||||
configuration: configuration,
|
||||
allowsModeSelection: allowsModeSelection
|
||||
)
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(viewModel: viewModel, api: api)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 667)
|
||||
let collection = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchTemplateCollection"
|
||||
} as? UICollectionView)
|
||||
await waitUntil { collection.numberOfItems(inSection: 0) == templateCount && !viewModel.isLoading }
|
||||
controller.view.layoutIfNeeded()
|
||||
collection.layoutIfNeeded()
|
||||
XCTAssertEqual(collection.numberOfItems(inSection: 0), templateCount)
|
||||
return (controller, viewModel, collection)
|
||||
}
|
||||
|
||||
func testAIRetouchTemplateCellExposesSelectedState() {
|
||||
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
|
||||
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
|
||||
|
||||
@@ -455,6 +455,51 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
/// 自动修图配置 ViewModel 测试。
|
||||
@MainActor
|
||||
final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
|
||||
func testModeToggleRetainsDraftButDisabledSubmissionOmitsTemplate() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(refinedTemplates: [
|
||||
TravelAlbumAIRetouchTemplate(id: 12, name: "清透", previewURL: ""),
|
||||
])
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true)
|
||||
await viewModel.loadTemplates(api: api)
|
||||
viewModel.selectMode(enabled: true)
|
||||
XCTAssertFalse(viewModel.canConfirm)
|
||||
viewModel.selectTemplate(id: 12)
|
||||
viewModel.selectMode(enabled: false)
|
||||
XCTAssertEqual(viewModel.pendingConfiguration, .disabled)
|
||||
XCTAssertEqual(viewModel.selectedTemplateId, 12)
|
||||
viewModel.selectMode(enabled: true)
|
||||
XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12)
|
||||
XCTAssertEqual(viewModel.selectedTemplateName, "清透")
|
||||
XCTAssertTrue(viewModel.canConfirm)
|
||||
}
|
||||
|
||||
func testOriginalModeRemainsConfirmableDuringTemplateLoadingAndFailure() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用")
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true)
|
||||
var couldConfirmWhileLoading = false
|
||||
viewModel.onStateChange = {
|
||||
if viewModel.isLoading { couldConfirmWhileLoading = viewModel.canConfirm }
|
||||
}
|
||||
await viewModel.loadTemplates(api: api)
|
||||
viewModel.onStateChange = nil
|
||||
XCTAssertTrue(couldConfirmWhileLoading)
|
||||
XCTAssertTrue(viewModel.canConfirm)
|
||||
XCTAssertEqual(viewModel.pendingConfiguration, .disabled)
|
||||
viewModel.selectMode(enabled: true)
|
||||
XCTAssertFalse(viewModel.canConfirm)
|
||||
}
|
||||
|
||||
func testTemplateOnlyEntryRequiresAISelectionAndCannotDisable() {
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: false)
|
||||
XCTAssertTrue(viewModel.isEnabled)
|
||||
XCTAssertFalse(viewModel.canConfirm)
|
||||
XCTAssertNil(viewModel.pendingConfiguration)
|
||||
viewModel.selectMode(enabled: false)
|
||||
XCTAssertTrue(viewModel.isEnabled)
|
||||
}
|
||||
|
||||
func testLoadsOnlyRefinedTemplatesAndKeepsSingleSelection() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
@@ -470,7 +515,7 @@ final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: 18,
|
||||
configuration: .disabled,
|
||||
startsWithModeSelection: true
|
||||
allowsModeSelection: true
|
||||
)
|
||||
|
||||
viewModel.selectMode(enabled: true)
|
||||
@@ -490,7 +535,7 @@ final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: 18,
|
||||
configuration: TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12),
|
||||
startsWithModeSelection: false
|
||||
allowsModeSelection: false
|
||||
)
|
||||
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
Reference in New Issue
Block a user