Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift
T

540 lines
22 KiB
Swift

//
// TravelAlbumAIRetouchTemplateViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
/// 页面使用的 collection section。
private enum Section: Hashable {
case templates(TravelAlbumAIRetouchTemplateCategory)
}
/// 页面使用的 diffable item。
private enum Item: Hashable {
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
}
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
private let api: any TravelAlbumServing
private let onSubmitted: () -> Void
private let titleLabel = UILabel()
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private let statusContainer = UIView()
private let statusIndicator = UIActivityIndicatorView(style: .medium)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let bottomBar = UIView()
private let bottomDivider = UIView()
private let footerStack = UIStackView()
private let quotaLabel = UILabel()
private let validationLabel = UILabel()
private let actionStack = UIStackView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
/// 创建 AI 修图模板选择 Sheet。
init(
viewModel: TravelAlbumAIRetouchTemplateViewModel,
api: any TravelAlbumServing,
onSubmitted: @escaping () -> Void
) {
self.viewModel = viewModel
self.api = api
self.onSubmitted = onSubmitted
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
configureSheetPresentation()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupUI() {
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
titleLabel.text = "AI修图"
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.accessibilityTraits = .header
collectionView.backgroundColor = .clear
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection"
collectionView.register(
TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchSectionHeader.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier
)
configureDataSource()
statusLabel.textColor = AIRetouchTemplateStyle.textSecondary
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
retryButton.setTitle("重试", for: .normal)
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton"
bottomBar.backgroundColor = .white
bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar"
bottomDivider.backgroundColor = AIRetouchTemplateStyle.border
validationLabel.textColor = AIRetouchTemplateStyle.danger
validationLabel.font = .systemFont(ofSize: 12, weight: .medium)
validationLabel.textAlignment = .center
validationLabel.numberOfLines = 0
validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel"
footerStack.axis = .vertical
footerStack.spacing = 8
footerStack.alignment = .fill
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
quotaLabel.textColor = AIRetouchTemplateStyle.primary
quotaLabel.textAlignment = .center
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
actionStack.axis = .horizontal
actionStack.spacing = 12
actionStack.distribution = .fillEqually
actionStack.alignment = .fill
configureCancelButton()
configureConfirmButton()
actionStack.addArrangedSubview(cancelButton)
actionStack.addArrangedSubview(confirmButton)
footerStack.addArrangedSubview(quotaLabel)
footerStack.addArrangedSubview(validationLabel)
footerStack.addArrangedSubview(actionStack)
view.addSubview(titleLabel)
view.addSubview(collectionView)
view.addSubview(statusContainer)
statusContainer.addSubview(statusIndicator)
statusContainer.addSubview(statusLabel)
statusContainer.addSubview(retryButton)
view.addSubview(bottomBar)
bottomBar.addSubview(bottomDivider)
bottomBar.addSubview(footerStack)
}
override func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(18)
make.height.equalTo(30)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
bottomDivider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
footerStack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
actionStack.snp.makeConstraints { make in
make.height.equalTo(48)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
statusContainer.snp.makeConstraints { make in
make.edges.equalTo(collectionView)
}
statusIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-32)
}
statusLabel.snp.makeConstraints { make in
make.top.equalTo(statusIndicator.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(40)
}
retryButton.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(12)
make.centerX.equalToSuperview()
make.height.equalTo(36)
make.bottom.lessThanOrEqualToSuperview()
}
}
override func bindActions() {
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() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitted = { [weak self] in
Task { @MainActor in
guard let self else { return }
self.dismiss(animated: true, completion: self.onSubmitted)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.loadTemplates(api: api) }
}
private func configureSheetPresentation() {
guard let sheet = sheetPresentationController else { return }
sheet.detents = [.large()]
sheet.selectedDetentIdentifier = .large
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
}
private func configureCancelButton() {
var configuration = UIButton.Configuration.filled()
configuration.title = "取消"
configuration.baseBackgroundColor = .white
configuration.baseForegroundColor = AIRetouchTemplateStyle.primary
configuration.background.cornerRadius = 14
configuration.background.strokeColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.55)
configuration.background.strokeWidth = 1
configuration.titleTextAttributesTransformer = buttonTitleTransformer
cancelButton.configuration = configuration
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
}
private func configureConfirmButton() {
var configuration = UIButton.Configuration.filled()
configuration.title = "确定"
configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary
configuration.baseForegroundColor = .white
configuration.background.cornerRadius = 14
configuration.titleTextAttributesTransformer = buttonTitleTransformer
confirmButton.configuration = configuration
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
}
private var buttonTitleTransformer: UIConfigurationTextAttributesTransformer {
UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
return attributes
}
}
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
[weak self] collectionView, indexPath, item in
guard let self else { return nil }
switch item {
case .template(let category, let template):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchTemplateCell
cell.apply(
template: template,
selected: self.viewModel.selectedTemplateId(for: category) == template.id
)
return cell
}
}
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard kind == UICollectionView.elementKindSectionHeader,
let self,
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section),
case .templates(let category) = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] else {
return nil
}
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchSectionHeader
header.apply(title: category.title, optional: self.viewModel.isOptional(category))
return header
}
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
guard let self,
self.dataSource != nil,
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
return nil
}
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(118),
heightDimension: .absolute(154)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.interGroupSpacing = 12
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18)
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(40)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
),
]
return section
}
}
@MainActor
private func applyViewModel() {
let isLoadFailure = viewModel.loadErrorMessage != nil
statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure
collectionView.isHidden = viewModel.isLoading || isLoadFailure
if viewModel.isLoading {
statusIndicator.startAnimating()
statusLabel.text = "正在加载修图模板…"
retryButton.isHidden = true
} else if let message = viewModel.loadErrorMessage {
statusIndicator.stopAnimating()
statusLabel.text = message
retryButton.isHidden = false
} else {
statusIndicator.stopAnimating()
statusLabel.text = nil
retryButton.isHidden = true
}
validationLabel.text = viewModel.validationMessage
validationLabel.isHidden = viewModel.validationMessage == nil
if let remainingQuota = viewModel.remainingQuota {
quotaLabel.text = "剩余 \(remainingQuota) 次"
quotaLabel.accessibilityLabel = "剩余\(remainingQuota)次修图额度"
quotaLabel.textColor = viewModel.requiredQuota > remainingQuota
? AIRetouchTemplateStyle.danger
: AIRetouchTemplateStyle.primary
} else {
quotaLabel.text = "剩余 -- 次"
quotaLabel.accessibilityLabel = "剩余修图额度暂不可用"
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
}
cancelButton.isEnabled = !viewModel.isSubmitting
confirmButton.isEnabled = viewModel.canSubmit
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
var confirmConfiguration = confirmButton.configuration
confirmConfiguration?.title = viewModel.isSubmitting ? "提交中" : "确定"
confirmConfiguration?.showsActivityIndicator = viewModel.isSubmitting
confirmButton.configuration = confirmConfiguration
isModalInPresentation = viewModel.isSubmitting
applySnapshot()
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
for category in viewModel.visibleCategories {
appendTemplates(category, to: &snapshot)
}
snapshot.reconfigureItems(snapshot.itemIdentifiers)
dataSource.apply(snapshot, animatingDifferences: true)
}
private func appendTemplates(
_ category: TravelAlbumAIRetouchTemplateCategory,
to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>
) {
let section = Section.templates(category)
snapshot.appendSections([section])
snapshot.appendItems(
viewModel.templates(for: category).map { Item.template(category, $0) },
toSection: section
)
}
@objc private func cancelTapped() {
guard !viewModel.isSubmitting else { return }
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard viewModel.canSubmit else {
if let message = viewModel.validationMessage { showToast(message) }
return
}
Task { await viewModel.submit(api: api) }
}
@objc private func retryTapped() {
Task { await viewModel.loadTemplates(api: api) }
}
}
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath),
case .template(let category, let template) = item else { return }
viewModel.toggleTemplate(id: template.id, category: category)
}
}
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
private let previewImageView = UIImageView()
private let nameLabel = UILabel()
private let checkView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.clipsToBounds = true
previewImageView.contentMode = .scaleAspectFill
previewImageView.clipsToBounds = true
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
nameLabel.font = .systemFont(ofSize: 13, weight: .medium)
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
nameLabel.textAlignment = .center
nameLabel.lineBreakMode = .byTruncatingTail
checkView.image = UIImage(systemName: "checkmark.circle.fill")
checkView.tintColor = AIRetouchTemplateStyle.primary
checkView.backgroundColor = .white
checkView.layer.cornerRadius = 10
contentView.addSubview(previewImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(checkView)
previewImageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(116)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(previewImageView.snp.bottom)
make.leading.trailing.equalToSuperview().inset(6)
make.bottom.equalToSuperview()
}
checkView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(8)
make.size.equalTo(20)
}
}
@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(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
nameLabel.text = template.name
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
previewImageView.contentMode = .scaleAspectFill
previewImageView.kf.setImage(
with: url,
placeholder: UIImage(systemName: "photo")?.withTintColor(
AIRetouchTemplateStyle.textSecondary,
renderingMode: .alwaysOriginal
)
)
} else {
previewImageView.image = UIImage(systemName: "photo")
previewImageView.tintColor = AIRetouchTemplateStyle.textSecondary
previewImageView.contentMode = .scaleAspectFit
}
contentView.layer.borderColor = (
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
).cgColor
contentView.layer.borderWidth = selected ? 2 : 1
checkView.isHidden = !selected
isSelected = selected
accessibilityLabel = template.name
accessibilityValue = selected ? "已选择" : "未选择"
accessibilityTraits = selected ? [.button, .selected] : [.button]
}
}
/// AI 修图模板分组标题,可附带“选填”标签。
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
private let titleLabel = UILabel()
private let optionalLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
optionalLabel.text = "选填"
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
optionalLabel.textColor = AIRetouchTemplateStyle.primary
optionalLabel.textAlignment = .center
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
optionalLabel.layer.cornerRadius = 8
optionalLabel.clipsToBounds = true
addSubview(titleLabel)
addSubview(optionalLabel)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalToSuperview()
}
optionalLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalTo(titleLabel)
make.width.equalTo(38)
make.height.equalTo(20)
make.trailing.lessThanOrEqualToSuperview().offset(-18)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新分组标题与选填标签。
func apply(title: String, optional: Bool) {
titleLabel.text = title
optionalLabel.isHidden = !optional
accessibilityLabel = optional ? "\(title),选填" : title
accessibilityTraits = .header
}
}
/// AI 修图模板页视觉常量。
private enum AIRetouchTemplateStyle {
static let primary = UIColor(hex: 0x1677FF)
static let pageBackground = UIColor(hex: 0xF7F9FC)
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7F8A9E)
static let border = UIColor(hex: 0xDCE4EF)
static let danger = UIColor(hex: 0xE53935)
}