// // TravelAlbumAIRetouchTemplateViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。 final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { /// 页面使用的 collection section。 private enum Section: Hashable { case templates(TravelAlbumAIRetouchTemplateCategory) case mode } /// 页面使用的 diffable item。 private enum Item: Hashable { case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate) case mode } 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! 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 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( TravelAlbumAIRetouchModeCell.self, forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.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 actionStack.axis = .horizontal actionStack.spacing = 12 actionStack.distribution = .fillEqually actionStack.alignment = .fill configureCancelButton() configureConfirmButton() actionStack.addArrangedSubview(cancelButton) actionStack.addArrangedSubview(confirmButton) 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(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 case .mode: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier, for: indexPath ) as! TravelAlbumAIRetouchModeCell cell.apply() 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 } switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] { case .templates: 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 case .mode: let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .absolute(72) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 18, bottom: 22, trailing: 18) 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 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() for category in viewModel.visibleCategories { appendTemplates(category, to: &snapshot) } snapshot.appendSections([.mode]) snapshot.appendItems([.mode], toSection: .mode) snapshot.reconfigureItems(snapshot.itemIdentifiers) dataSource.apply(snapshot, animatingDifferences: true) } private func appendTemplates( _ category: TravelAlbumAIRetouchTemplateCategory, to snapshot: inout NSDiffableDataSourceSnapshot ) { 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 修图模式卡片,展示计费方式与剩余张数占位。 final class TravelAlbumAIRetouchModeCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumAIRetouchModeCell" private let titleLabel = UILabel() private let remainingLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white contentView.layer.cornerRadius = 14 contentView.layer.borderWidth = 1 contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) titleLabel.textColor = AIRetouchTemplateStyle.textPrimary remainingLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium) remainingLabel.textColor = AIRetouchTemplateStyle.primary remainingLabel.textAlignment = .right contentView.addSubview(titleLabel) contentView.addSubview(remainingLabel) titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() } remainingLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-16) make.centerY.equalToSuperview() make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 应用当前固定修图模式与额度占位文案。 func apply() { titleLabel.text = "AI精修 按张收费" remainingLabel.text = "剩余--张" accessibilityIdentifier = "travelAlbum.aiRetouchModeCell" accessibilityLabel = "AI精修,按张收费,剩余张数暂不可用" } } /// 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) }