feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user