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! 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(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() 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 } }