393 lines
19 KiB
Swift
393 lines
19 KiB
Swift
import SnapKit
|
||
import UIKit
|
||
|
||
/// 相册自动修图设置:在同一页切换修图方式、展开模板并确认草稿。
|
||
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController {
|
||
/// 确认草稿后通知调用方保存相册配置。
|
||
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
|
||
|
||
private let viewModel: TravelAlbumAutoRetouchSettingViewModel
|
||
private let api: any TravelAlbumServing
|
||
private let titleLabel = UILabel()
|
||
private let subtitleLabel = UILabel()
|
||
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) {
|
||
self.viewModel = viewModel
|
||
self.api = api
|
||
super.init(nibName: nil, bundle: nil)
|
||
modalPresentationStyle = .pageSheet
|
||
if let sheet = sheetPresentationController {
|
||
sheet.detents = [.large()]
|
||
sheet.selectedDetentIdentifier = .large
|
||
sheet.prefersGrabberVisible = true
|
||
sheet.preferredCornerRadius = 24
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = UIColor(hex: 0xF6F8FC)
|
||
titleLabel.text = viewModel.allowsModeSelection ? "选择修图方式" : "选择修图模板"
|
||
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
|
||
titleLabel.textColor = AppColor.textPrimary
|
||
titleLabel.accessibilityTraits = .header
|
||
subtitleLabel.text = "设置仅对后续上传的照片生效"
|
||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||
subtitleLabel.textColor = AppColor.textSecondary
|
||
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
|
||
|
||
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)
|
||
|
||
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.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(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() {
|
||
headerStack.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
}
|
||
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)
|
||
}
|
||
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)
|
||
}
|
||
confirmButton.snp.makeConstraints { make in
|
||
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
|
||
make.trailing.equalToSuperview().offset(-16)
|
||
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)
|
||
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||
viewModel.onStateChange = { [weak self] in
|
||
Task { @MainActor in self?.applyViewModel() }
|
||
}
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
applyViewModel()
|
||
Task { await viewModel.loadTemplates(api: api) }
|
||
}
|
||
|
||
private func configureDataSource() {
|
||
templateDataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>(
|
||
collectionView: templateCollectionView
|
||
) { [weak self] collectionView, indexPath, template in
|
||
guard let self else { return nil }
|
||
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 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()
|
||
} else {
|
||
activityIndicator.stopAnimating()
|
||
}
|
||
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])
|
||
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 = 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 retryTapped() { Task { await viewModel.loadTemplates(api: api) } }
|
||
@objc private func confirmTapped() {
|
||
guard viewModel.canConfirm, let configuration = viewModel.pendingConfiguration else { return }
|
||
let completion = onConfirm
|
||
dismiss(animated: true) { completion?(configuration) }
|
||
}
|
||
}
|
||
|
||
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 AutoRetouchModeControl: UIControl {
|
||
private let iconContainer = UIView()
|
||
private let iconView = UIImageView()
|
||
private let checkView = UIImageView()
|
||
private let titleLabel = UILabel()
|
||
|
||
/// 创建可点击的方式卡片;所有内容由整个控件统一响应触摸。
|
||
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)
|
||
let detailLabel = UILabel()
|
||
detailLabel.text = detail
|
||
detailLabel.font = .systemFont(ofSize: 12)
|
||
detailLabel.textColor = AppColor.textSecondary
|
||
detailLabel.numberOfLines = 2
|
||
checkView.contentMode = .scaleAspectFit
|
||
[iconContainer, iconView, checkView, titleLabel, detailLabel].forEach {
|
||
$0.isUserInteractionEnabled = false
|
||
addSubview($0)
|
||
}
|
||
iconContainer.snp.makeConstraints { make in
|
||
make.top.leading.equalToSuperview().offset(12)
|
||
make.size.equalTo(32)
|
||
}
|
||
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.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.bottom.equalToSuperview().inset(12)
|
||
}
|
||
isAccessibilityElement = true
|
||
accessibilityLabel = "\(title),\(detail)"
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
/// 更新卡片背景与单选语义。
|
||
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 ? "已选择" : "未选择"
|
||
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||
}
|
||
}
|