Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift
T
lujiuyin d04641b623 feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
2026-08-27 16:03:40 +08:00

358 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 activityIndicator = UIActivityIndicatorView(style: .medium)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
/// 创建设置 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
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupUI() {
view.backgroundColor = .white
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
subtitleLabel.numberOfLines = 0
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = AppColor.textPrimary
backButton.accessibilityLabel = "返回修图方式"
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = 116
tableView.delegate = self
tableView.register(AutoRetouchOptionCell.self, forCellReuseIdentifier: AutoRetouchOptionCell.reuseIdentifier)
configureDataSource()
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
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)
}
override func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(56)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(28)
}
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalTo(titleLabel)
make.size.equalTo(36)
}
cancelButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
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)
}
}
override func bindActions() {
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() }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.loadTemplates(api: api) }
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Int, Item>(tableView: tableView) { [weak self] tableView, indexPath, item 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
)
}
return cell
}
}
@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
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>()
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)
}
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)
}
}
@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 }
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)
}
}
}
/// 自动修图方式或模板列表单元,提供效果图、说明和明确单选状态。
private final class AutoRetouchOptionCell: UITableViewCell {
static let reuseIdentifier = "AutoRetouchOptionCell"
private let card = UIView()
private let previewImageView = 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
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
detailLabel.font = .systemFont(ofSize: 13)
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)
}
previewImageView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.top.bottom.equalToSuperview().inset(10)
make.width.equalTo(previewImageView.snp.height)
}
selectionImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.size.equalTo(24)
}
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)
}
detailLabel.snp.makeConstraints { make in
make.leading.trailing.equalTo(titleLabel)
make.top.equalTo(card.snp.centerY).offset(4)
}
}
@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)"
accessibilityValue = selected ? "已选择" : "未选择"
isAccessibilityElement = true
}
}