From 396597a160bbc4d3ace69c9163a6fbfa0cdfc09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Thu, 27 Aug 2026 17:15:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E4=BF=AE=E5=9B=BE=E8=AE=BE=E7=BD=AE=E4=B8=8E=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。 --- ...avelAlbumAutoRetouchSettingViewModel.swift | 42 +- ...CreateTravelAlbumSheetViewController.swift | 2 +- ...utoRetouchSettingSheetViewController.swift | 495 ++++++++++-------- .../WiredCameraTransferViewController.swift | 2 +- ...TravelAlbumDetailViewControllerTests.swift | 207 ++++++++ .../TravelAlbumViewModelTests.swift | 49 +- 6 files changed, 538 insertions(+), 259 deletions(-) diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAutoRetouchSettingViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAutoRetouchSettingViewModel.swift index 94d6787..8d54e8f 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAutoRetouchSettingViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAutoRetouchSettingViewModel.swift @@ -2,15 +2,9 @@ import Foundation /// 自动修图设置状态,负责加载真实精修模板、单选和配置校验,不直接创建修图任务。 final class TravelAlbumAutoRetouchSettingViewModel { - /// 设置页当前层级。 - enum Stage: Sendable, Equatable { - case mode - case templates - } - let scenicId: Int - let startsWithModeSelection: Bool - private(set) var stage: Stage + /// 是否在模板上方展示修图方式;创建页已选择 AI 时只展示模板。 + let allowsModeSelection: Bool private(set) var templates: [TravelAlbumAIRetouchTemplate] = [] private(set) var selectedTemplateId: Int? private(set) var isEnabled: Bool @@ -23,15 +17,24 @@ final class TravelAlbumAutoRetouchSettingViewModel { init( scenicId: Int, configuration: TravelAlbumAutoRetouchConfiguration, - startsWithModeSelection: Bool + allowsModeSelection: Bool ) { self.scenicId = scenicId - self.startsWithModeSelection = startsWithModeSelection - self.stage = startsWithModeSelection ? .mode : .templates - self.isEnabled = configuration.enabled + self.allowsModeSelection = allowsModeSelection + self.isEnabled = configuration.enabled || !allowsModeSelection self.selectedTemplateId = configuration.refinedTemplateId } + /// 关闭自动修图不依赖模板加载;开启时必须选择有效模板。 + var canConfirm: Bool { + !isEnabled || (!isLoading && pendingConfiguration != nil) + } + + /// 当前草稿模板名称,用于固定底部的选择摘要。 + var selectedTemplateName: String? { + templates.first { $0.id == selectedTemplateId }?.name + } + /// 当前可提交配置;启用状态必须已经选择服务端模板。 var pendingConfiguration: TravelAlbumAutoRetouchConfiguration? { if !isEnabled { return .disabled } @@ -75,14 +78,10 @@ final class TravelAlbumAutoRetouchSettingViewModel { } } - /// 选择一级修图方式。 + /// 切换修图方式,保留本次草稿模板以便再次开启;关闭配置提交时仍不携带模板。 func selectMode(enabled: Bool) { + guard allowsModeSelection else { return } isEnabled = enabled - if enabled { - stage = .templates - } else { - selectedTemplateId = nil - } notify() } @@ -94,12 +93,5 @@ final class TravelAlbumAutoRetouchSettingViewModel { notify() } - /// 从模板列表返回修图方式层级。 - func returnToModeSelection() { - guard startsWithModeSelection else { return } - stage = .mode - notify() - } - private func notify() { onStateChange?() } } diff --git a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift index e3a18fe..6ae10f2 100644 --- a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift +++ b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift @@ -261,7 +261,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { let settingViewModel = TravelAlbumAutoRetouchSettingViewModel( scenicId: AppStore.shared.session.currentScenicId, configuration: autoRetouchConfiguration, - startsWithModeSelection: false + allowsModeSelection: false ) let controller = TravelAlbumAutoRetouchSettingSheetViewController( viewModel: settingViewModel, diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift index c401680..342022a 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift @@ -1,44 +1,45 @@ -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 headerStack = UIStackView() + private let modeStack = UIStackView() + private let originalOption = AutoRetouchModeControl(title: "不修图", detail: "保留相机原始照片", symbol: "photo") + private let aiOption = AutoRetouchModeControl(title: "AI 修图", detail: "上传后自动套用模板", symbol: "wand.and.stars") + private let contentRegion = UIView() + private let templateHeader = UIStackView() + private lazy var templateCollectionView = UICollectionView(frame: .zero, collectionViewLayout: makeTemplateLayout()) + private var templateDataSource: UICollectionViewDiffableDataSource! + private let originalHint = UIStackView() + private let statusStack = UIStackView() private let activityIndicator = UIActivityIndicatorView(style: .medium) private let statusLabel = UILabel() private let retryButton = UIButton(type: .system) + private let footer = UIView() + private let selectionLabel = UILabel() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) + private var lastTemplatesVisible: Bool? - /// 创建设置 Sheet。 - init( - viewModel: TravelAlbumAutoRetouchSettingViewModel, - api: any TravelAlbumServing - ) { + /// 创建设置 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 + if let sheet = sheetPresentationController { + sheet.detents = [.large()] + sheet.selectedDetentIdentifier = .large + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 24 } } @@ -46,100 +47,167 @@ final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupUI() { - view.backgroundColor = .white - titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) + view.backgroundColor = UIColor(hex: 0xF6F8FC) + titleLabel.text = viewModel.allowsModeSelection ? "选择修图方式" : "选择修图模板" + titleLabel.font = .systemFont(ofSize: 22, weight: .bold) titleLabel.textColor = AppColor.textPrimary - titleLabel.textAlignment = .center + titleLabel.accessibilityTraits = .header + subtitleLabel.text = "设置仅对后续上传的照片生效" subtitleLabel.font = .systemFont(ofSize: 13) subtitleLabel.textColor = AppColor.textSecondary - subtitleLabel.textAlignment = .center - subtitleLabel.numberOfLines = 0 + headerStack.axis = .vertical + headerStack.spacing = 8 + headerStack.addArrangedSubview(titleLabel) + headerStack.addArrangedSubview(subtitleLabel) + headerStack.setCustomSpacing(20, after: subtitleLabel) + modeStack.axis = .horizontal + modeStack.distribution = .fillEqually + modeStack.spacing = 12 + modeStack.accessibilityIdentifier = "travelAlbum.autoRetouchModeOptions" + originalOption.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalOption" + aiOption.accessibilityIdentifier = "travelAlbum.autoRetouchAIOption" + modeStack.addArrangedSubview(originalOption) + modeStack.addArrangedSubview(aiOption) + headerStack.addArrangedSubview(modeStack) + modeStack.isHidden = !viewModel.allowsModeSelection - backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) - backButton.tintColor = AppColor.textPrimary - backButton.accessibilityLabel = "返回修图方式" + let templateTitle = UILabel() + templateTitle.text = viewModel.allowsModeSelection ? "选择修图模板" : "精修模板" + templateTitle.font = .systemFont(ofSize: 17, weight: .semibold) + templateTitle.textColor = AppColor.textPrimary + templateTitle.accessibilityTraits = .header + let templateTips = UILabel() + templateTips.text = "单选模板 · 点击预览查看前后效果" + templateTips.font = .systemFont(ofSize: 12) + templateTips.textColor = AppColor.textSecondary + templateHeader.axis = .vertical + templateHeader.spacing = 5 + templateHeader.addArrangedSubview(templateTitle) + templateHeader.addArrangedSubview(templateTips) - tableView.backgroundColor = .white - tableView.separatorStyle = .none - tableView.rowHeight = 116 - tableView.delegate = self - tableView.register(AutoRetouchOptionCell.self, forCellReuseIdentifier: AutoRetouchOptionCell.reuseIdentifier) + templateCollectionView.backgroundColor = .clear + templateCollectionView.alwaysBounceVertical = true + templateCollectionView.accessibilityIdentifier = "travelAlbum.autoRetouchTemplateCollection" + templateCollectionView.delegate = self + templateCollectionView.register(TravelAlbumAIRetouchTemplateCell.self, + forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier) configureDataSource() + let hintIcon = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled")) + hintIcon.tintColor = AppColor.primary.withAlphaComponent(0.6) + hintIcon.contentMode = .scaleAspectFit + let hintTitle = UILabel() + hintTitle.text = "保留原图,直接上传" + hintTitle.font = .systemFont(ofSize: 17, weight: .semibold) + hintTitle.textColor = AppColor.textPrimary + let hintDetail = UILabel() + hintDetail.text = "选择上方 AI 修图,即可在这里挑选模板" + hintDetail.font = .systemFont(ofSize: 13) + hintDetail.textColor = AppColor.textSecondary + hintDetail.numberOfLines = 2 + hintDetail.textAlignment = .center + originalHint.axis = .vertical + originalHint.alignment = .center + originalHint.spacing = 12 + originalHint.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalHint" + originalHint.addArrangedSubview(hintIcon) + originalHint.addArrangedSubview(hintTitle) + originalHint.addArrangedSubview(hintDetail) + hintIcon.snp.makeConstraints { $0.size.equalTo(48) } + + statusStack.axis = .vertical + statusStack.alignment = .center + statusStack.spacing = 12 + statusStack.addArrangedSubview(activityIndicator) + statusStack.addArrangedSubview(statusLabel) + statusStack.addArrangedSubview(retryButton) + activityIndicator.color = AppColor.primary 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 + retryButton.setTitle("重新加载", for: .normal) + retryButton.tintColor = AppColor.primary + retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) + retryButton.snp.makeConstraints { $0.height.equalTo(44) } + footer.backgroundColor = .white + footer.layer.shadowColor = UIColor(hex: 0x182B49).cgColor + footer.layer.shadowOpacity = 0.04 + footer.layer.shadowRadius = 12 + footer.layer.shadowOffset = CGSize(width: 0, height: -3) + selectionLabel.font = .systemFont(ofSize: 13, weight: .medium) + selectionLabel.textColor = AppColor.textSecondary + selectionLabel.lineBreakMode = .byTruncatingTail + selectionLabel.accessibilityIdentifier = "travelAlbum.autoRetouchSelectionSummary" 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) + view.addSubview(headerStack) + view.addSubview(contentRegion) + contentRegion.addSubview(templateHeader) + contentRegion.addSubview(templateCollectionView) + contentRegion.addSubview(originalHint) + contentRegion.addSubview(statusStack) + view.addSubview(footer) + footer.addSubview(selectionLabel) + footer.addSubview(cancelButton) + footer.addSubview(confirmButton) } override func setupConstraints() { - titleLabel.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(12) - make.leading.trailing.equalToSuperview().inset(56) + headerStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(16) + make.leading.trailing.equalToSuperview().inset(16) } - subtitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(8) - make.leading.trailing.equalToSuperview().inset(28) + contentRegion.snp.makeConstraints { make in + make.top.equalTo(headerStack.snp.bottom).offset(22) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(footer.snp.top).offset(-8) } - backButton.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(18) - make.centerY.equalTo(titleLabel) - make.size.equalTo(36) + templateHeader.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(16) + } + templateCollectionView.snp.makeConstraints { make in + make.top.equalTo(templateHeader.snp.bottom).offset(12) + make.leading.trailing.bottom.equalToSuperview() + } + originalHint.snp.makeConstraints { make in + make.centerY.equalToSuperview().offset(-12) + make.leading.trailing.equalToSuperview().inset(24) + } + statusStack.snp.makeConstraints { make in + make.center.equalTo(templateCollectionView) + make.leading.trailing.equalToSuperview().inset(32) + } + footer.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + selectionLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(14) + make.leading.trailing.equalToSuperview().inset(16) } cancelButton.snp.makeConstraints { make in + make.top.equalTo(selectionLabel.snp.bottom).offset(12) make.leading.equalToSuperview().offset(16) + make.height.equalTo(50) + make.width.equalTo(96) 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) + make.top.bottom.equalTo(cancelButton) } } override func bindActions() { + originalOption.addTarget(self, action: #selector(originalTapped), for: .touchUpInside) + aiOption.addTarget(self, action: #selector(aiTapped), for: .touchUpInside) 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() } @@ -153,205 +221,172 @@ final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController } private func configureDataSource() { - dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in + templateDataSource = UICollectionViewDiffableDataSource( + collectionView: templateCollectionView + ) { [weak self] collectionView, indexPath, template 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 - ) - } + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier, for: indexPath + ) as! TravelAlbumAIRetouchTemplateCell + cell.apply(template: template, selected: self.viewModel.selectedTemplateId == template.id) + cell.onPreviewTapped = { [weak self] in self?.showPreview(for: template) } return cell } } + private func makeTemplateLayout() -> UICollectionViewCompositionalLayout { + let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1) + )) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(156)), + subitem: item, count: 3 + ) + group.interItemSpacing = .fixed(12) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 12 + section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 16, bottom: 12, trailing: 16) + return UICollectionViewCompositionalLayout(section: section) + } + + private func showPreview(for template: TravelAlbumAIRetouchTemplate) { + guard presentedViewController == nil else { return } + guard let content = template.comparisonContent else { showToast("暂无对比预览"); return } + present(BeforeAfterComparisonViewController(viewModel: BeforeAfterComparisonViewModel(content: content)), animated: true) + } + @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 + let showsTemplates = viewModel.isEnabled + originalOption.apply(selected: !showsTemplates) + aiOption.apply(selected: showsTemplates) + confirmButton.isEnabled = viewModel.canConfirm + confirmButton.alpha = viewModel.canConfirm ? 1 : 0.4 + selectionLabel.text = !showsTemplates ? "将保留原图,不进行 AI 修图" + : viewModel.selectedTemplateName.map { "已选择:\($0)" } ?? "请选择一个修图模板" + selectionLabel.textColor = showsTemplates && viewModel.selectedTemplateName != nil ? AppColor.primary : AppColor.textSecondary + let updateVisibility = { + self.originalHint.isHidden = showsTemplates + self.templateHeader.isHidden = !showsTemplates + self.templateCollectionView.isHidden = !showsTemplates || self.viewModel.isLoading || self.viewModel.errorMessage != nil + self.statusStack.isHidden = !showsTemplates || (!self.viewModel.isLoading && self.viewModel.errorMessage == nil) + } + if let previous = lastTemplatesVisible, previous != showsTemplates, + view.window != nil, !UIAccessibility.isReduceMotionEnabled { + UIView.transition(with: contentRegion, duration: 0.2, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: updateVisibility) + } else { + updateVisibility() + } + lastTemplatesVisible = showsTemplates 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() + activityIndicator.isHidden = !viewModel.isLoading + statusLabel.text = viewModel.isLoading ? "正在加载修图模板…" : viewModel.errorMessage + retryButton.isHidden = viewModel.isLoading + let previousTemplates = Set(templateDataSource.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) + snapshot.appendItems(viewModel.templates) + snapshot.reconfigureItems(viewModel.templates.filter(previousTemplates.contains)) + templateDataSource.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) - } + button.layer.cornerRadius = 14 + button.backgroundColor = filled ? AppColor.primary : .white + button.setTitleColor(filled ? .white : AppColor.textSecondary, for: .normal) + button.layer.borderWidth = filled ? 0 : 1 + button.layer.borderColor = UIColor(hex: 0xDFE5EF).cgColor } + @objc private func originalTapped() { viewModel.selectMode(enabled: false) } + @objc private func aiTapped() { viewModel.selectMode(enabled: true) } @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 } + guard viewModel.canConfirm, 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) - } +extension TravelAlbumAutoRetouchSettingSheetViewController: UICollectionViewDelegate { + /// 点击模板更新草稿;预览按钮独立处理,不改变选择。 + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let template = templateDataSource.itemIdentifier(for: indexPath) else { return } + viewModel.selectTemplate(id: template.id) } } -/// 自动修图方式或模板列表单元,提供效果图、说明和明确单选状态。 -private final class AutoRetouchOptionCell: UITableViewCell { - static let reuseIdentifier = "AutoRetouchOptionCell" - private let card = UIView() - private let previewImageView = UIImageView() +/// 修图方式单选卡片,使用图标、边框和勾选共同传达当前选择。 +private final class AutoRetouchModeControl: UIControl { + private let iconContainer = UIView() + private let iconView = UIImageView() + private let checkView = 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 + /// 创建可点击的方式卡片;所有内容由整个控件统一响应触摸。 + init(title: String, detail: String, symbol: String) { + super.init(frame: .zero) + layer.cornerRadius = 16 + layer.borderWidth = 1 + iconContainer.layer.cornerRadius = 10 + iconView.image = UIImage(systemName: symbol) + iconView.contentMode = .scaleAspectFit + titleLabel.text = title titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) - titleLabel.textColor = AppColor.textPrimary - detailLabel.font = .systemFont(ofSize: 13) + let detailLabel = UILabel() + detailLabel.text = detail + detailLabel.font = .systemFont(ofSize: 12) 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) + checkView.contentMode = .scaleAspectFit + [iconContainer, iconView, checkView, titleLabel, detailLabel].forEach { + $0.isUserInteractionEnabled = false + addSubview($0) } - previewImageView.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(12) - make.top.bottom.equalToSuperview().inset(10) - make.width.equalTo(previewImageView.snp.height) + iconContainer.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(12) + make.size.equalTo(32) } - selectionImageView.snp.makeConstraints { make in - make.trailing.equalToSuperview().offset(-16) - make.centerY.equalToSuperview() - make.size.equalTo(24) + iconView.snp.makeConstraints { $0.edges.equalTo(iconContainer).inset(7) } + checkView.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(12) + make.size.equalTo(22) } 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) + make.top.equalTo(iconContainer.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview().inset(12) } detailLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) make.leading.trailing.equalTo(titleLabel) - make.top.equalTo(card.snp.centerY).offset(4) + make.bottom.equalToSuperview().inset(12) } + isAccessibilityElement = true + accessibilityLabel = "\(title),\(detail)" } @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)" + /// 更新卡片背景与单选语义。 + func apply(selected: Bool) { + isSelected = selected + backgroundColor = selected ? AppColor.primaryLight : .white + layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xDFE5EF)).cgColor + layer.borderWidth = selected ? 1.5 : 1 + iconContainer.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEFF2F7) + iconView.tintColor = selected ? .white : AppColor.textSecondary + titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary + checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") + checkView.tintColor = selected ? AppColor.primary : UIColor(hex: 0xC7CFDC) accessibilityValue = selected ? "已选择" : "未选择" - isAccessibilityElement = true + accessibilityTraits = selected ? [.button, .selected] : [.button] } } diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index ef95711..8868be8 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -787,7 +787,7 @@ final class WiredCameraTransferViewController: BaseViewController { let settingViewModel = TravelAlbumAutoRetouchSettingViewModel( scenicId: AppStore.shared.session.currentScenicId, configuration: viewModel.autoRetouchConfiguration, - startsWithModeSelection: true + allowsModeSelection: true ) let controller = TravelAlbumAutoRetouchSettingSheetViewController( viewModel: settingViewModel, diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index 057652e..177aadc 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -955,6 +955,213 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 2) } + func testAutoRetouchGridUsesThreeColumnsAndKeepsFooterAndSelectionWhileScrolling() async throws { + let fixture = try await makeAutoRetouchSheet(templateCount: 19) + let controller = fixture.controller + let collection = fixture.collection + let confirm = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.autoRetouchConfirmButton" + } as? UIButton) + XCTAssertTrue(collection.collectionViewLayout is UICollectionViewCompositionalLayout) + XCTAssertFalse(confirm.isDescendant(of: collection)) + + for width: CGFloat in [375, 390, 430] { + controller.view.frame = CGRect(x: 0, y: 0, width: width, height: 667) + controller.view.layoutIfNeeded() + collection.layoutIfNeeded() + let frames = try (0 ..< 4).map { index in + try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: index, section: 0))).frame + } + XCTAssertEqual(frames[0].minX, 16, accuracy: 0.5) + XCTAssertEqual(frames[0].width, (width - 56) / 3, accuracy: 0.5) + XCTAssertEqual(frames[0].height, 156) + XCTAssertEqual(frames[1].minY, frames[0].minY) + XCTAssertEqual(frames[2].minY, frames[0].minY) + XCTAssertEqual(frames[1].minX - frames[0].maxX, 12, accuracy: 0.5) + XCTAssertEqual(frames[2].minX - frames[1].maxX, 12, accuracy: 0.5) + XCTAssertEqual(frames[3].minX, frames[0].minX) + XCTAssertEqual(frames[3].minY - frames[0].maxY, 12, accuracy: 0.5) + XCTAssertLessThanOrEqual(collection.contentSize.width, collection.bounds.width) + } + + let footerFrame = confirm.convert(confirm.bounds, to: controller.view) + let last = IndexPath(item: 18, section: 0) + collection.scrollToItem(at: last, at: .bottom, animated: false) + collection.layoutIfNeeded() + let lastCell = try XCTUnwrap(collection.cellForItem(at: last) as? TravelAlbumAIRetouchTemplateCell) + XCTAssertEqual(lastCell.frame.minX, 16, accuracy: 0.5) + XCTAssertGreaterThan(collection.contentOffset.y, 0) + let scrollOffset = collection.contentOffset + collection.delegate?.collectionView?(collection, didSelectItemAt: last) + await waitUntil { lastCell.accessibilityValue == "已选择" } + XCTAssertEqual(fixture.viewModel.pendingConfiguration?.refinedTemplateId, 19) + XCTAssertEqual(collection.contentOffset, scrollOffset) + XCTAssertEqual(confirm.convert(confirm.bounds, to: controller.view), footerFrame) + XCTAssertLessThanOrEqual(collection.convert(collection.bounds, to: controller.view).maxY, footerFrame.minY) + XCTAssertTrue(confirm.isEnabled) + collection.delegate?.collectionView?(collection, didSelectItemAt: last) + XCTAssertEqual(fixture.viewModel.selectedTemplateId, 19) + + collection.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: false) + collection.layoutIfNeeded() + let first = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: 0, section: 0))) + XCTAssertEqual(first.accessibilityValue, "未选择") + collection.scrollToItem(at: last, at: .bottom, animated: false) + collection.layoutIfNeeded() + XCTAssertEqual(collection.cellForItem(at: last)?.accessibilityValue, "已选择") + } + + func testAutoRetouchModeSelectionExpandsTemplatesInlineAndKeepsDraftWhenToggling() async throws { + let fixture = try await makeAutoRetouchSheet(templateCount: 19, allowsModeSelection: true, configuration: .disabled) + let controller = fixture.controller + let original = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.autoRetouchOriginalOption" + } as? UIControl) + let ai = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.autoRetouchAIOption" + } as? UIControl) + let confirm = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.autoRetouchConfirmButton" + } as? UIButton) + let summary = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.autoRetouchSelectionSummary" + } as? UILabel) + XCTAssertTrue(original.isSelected) + XCTAssertTrue(fixture.collection.isHidden) + XCTAssertTrue(confirm.isEnabled) + let modeFrame = ai.convert(ai.bounds, to: controller.view) + ai.sendActions(for: .touchUpInside) + await waitUntil { !fixture.collection.isHidden } + controller.view.layoutIfNeeded() + fixture.collection.layoutIfNeeded() + XCTAssertTrue(ai.isSelected) + XCTAssertFalse(original.isSelected) + XCTAssertFalse(confirm.isEnabled) + XCTAssertNil(controller.presentedViewController) + XCTAssertEqual(ai.convert(ai.bounds, to: controller.view), modeFrame) + XCTAssertGreaterThan(fixture.collection.convert(fixture.collection.bounds, to: controller.view).minY, modeFrame.maxY) + fixture.viewModel.selectTemplate(id: 4) + await waitUntil { summary.text == "已选择:模板4" } + let last = IndexPath(item: 18, section: 0) + fixture.collection.scrollToItem(at: last, at: .bottom, animated: false) + fixture.collection.layoutIfNeeded() + let offset = fixture.collection.contentOffset + original.sendActions(for: .touchUpInside) + await waitUntil { fixture.collection.isHidden } + XCTAssertTrue(confirm.isEnabled) + XCTAssertEqual(fixture.viewModel.pendingConfiguration, .disabled) + ai.sendActions(for: .touchUpInside) + await waitUntil { !fixture.collection.isHidden } + XCTAssertEqual(fixture.viewModel.selectedTemplateId, 4) + XCTAssertEqual(fixture.collection.contentOffset, offset) + XCTAssertEqual(summary.text, "已选择:模板4") + XCTAssertTrue(confirm.isEnabled) + fixture.collection.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: false) + controller.view.layoutIfNeeded() + fixture.collection.layoutIfNeeded() + let image = UIGraphicsImageRenderer(bounds: controller.view.bounds).image { context in + controller.view.layer.render(in: context.cgContext) + } + let attachment = XCTAttachment(image: image) + attachment.name = "自动修图-同页模板选择" + attachment.lifetime = .keepAlways + add(attachment) + } + + func testAutoRetouchPreviewDoesNotSelectTemplateAndReturnsToSameScrollPosition() async throws { + let fixture = try await makeAutoRetouchSheet(templateCount: 19) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let host = UIViewController() + window.rootViewController = host + window.makeKeyAndVisible() + defer { window.isHidden = true } + await withCheckedContinuation { (continuation: CheckedContinuation) in + host.present(fixture.controller, animated: false) { continuation.resume() } + } + fixture.controller.view.layoutIfNeeded() + let collection = fixture.collection + let last = IndexPath(item: 18, section: 0) + collection.scrollToItem(at: last, at: .bottom, animated: false) + collection.layoutIfNeeded() + let offset = collection.contentOffset + let cell = try XCTUnwrap(collection.cellForItem(at: last)) + let preview = try XCTUnwrap(cell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton" + } as? UIButton) + preview.sendActions(for: .touchUpInside) + await waitUntil { fixture.controller.presentedViewController is BeforeAfterComparisonViewController } + let comparison = try XCTUnwrap(fixture.controller.presentedViewController as? BeforeAfterComparisonViewController) + XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1) + XCTAssertEqual(fixture.viewModel.pendingConfiguration?.refinedTemplateId, 1) + let returned = expectation(description: "Return from comparison") + // 等待展示动画完成,再模拟预览返回。 + if let transition = comparison.transitionCoordinator { + transition.animate(alongsideTransition: nil) { _ in + comparison.dismiss(animated: false) { returned.fulfill() } + } + } else { + comparison.dismiss(animated: false) { returned.fulfill() } + } + await fulfillment(of: [returned], timeout: 3) + XCTAssertNil(fixture.controller.presentedViewController) + XCTAssertEqual(collection.contentOffset, offset) + XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1) + } + + func testAutoRetouchMissingComparisonKeepsSelectionAndShowsMessage() async throws { + let fixture = try await makeAutoRetouchSheet(templateCount: 2, hasComparison: false) + let cell = try XCTUnwrap(fixture.collection.cellForItem(at: IndexPath(item: 1, section: 0))) + let preview = try XCTUnwrap(cell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton" + } as? UIButton) + preview.sendActions(for: .touchUpInside) + XCTAssertNil(fixture.controller.presentedViewController) + XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1) + XCTAssertTrue(fixture.controller.view.allLabels().contains { $0.text == "暂无对比预览" }) + } + + private func makeAutoRetouchSheet( + templateCount: Int, + allowsModeSelection: Bool = false, + hasComparison: Bool = true, + configuration: TravelAlbumAutoRetouchConfiguration = TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 1) + ) async throws -> ( + controller: TravelAlbumAutoRetouchSettingSheetViewController, + viewModel: TravelAlbumAutoRetouchSettingViewModel, + collection: UICollectionView + ) { + let api = TravelAlbumMockAPI() + let imageURL = FileManager.default.temporaryDirectory.appendingPathComponent("auto-retouch-\(UUID().uuidString).png") + let imageData = try XCTUnwrap(UIImage(named: "ai_retouch_template_placeholder")?.pngData()) + try imageData.write(to: imageURL) + addTeardownBlock { try? FileManager.default.removeItem(at: imageURL) } + api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( + refinedTemplates: (1 ... templateCount).map { + TravelAlbumAIRetouchTemplate( + id: $0, name: "模板\($0)", previewURL: "", + beforeURL: hasComparison ? imageURL.absoluteString : "", + afterURL: hasComparison ? imageURL.absoluteString : "" + ) + } + ) + let viewModel = TravelAlbumAutoRetouchSettingViewModel( + scenicId: 18, + configuration: configuration, + allowsModeSelection: allowsModeSelection + ) + let controller = TravelAlbumAutoRetouchSettingSheetViewController(viewModel: viewModel, api: api) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 667) + let collection = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.autoRetouchTemplateCollection" + } as? UICollectionView) + await waitUntil { collection.numberOfItems(inSection: 0) == templateCount && !viewModel.isLoading } + controller.view.layoutIfNeeded() + collection.layoutIfNeeded() + XCTAssertEqual(collection.numberOfItems(inSection: 0), templateCount) + return (controller, viewModel, collection) + } + func testAIRetouchTemplateCellExposesSelectedState() { let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero) let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "") diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 8b6fce8..495be94 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -455,6 +455,51 @@ final class TravelAlbumDetailViewModelTests: XCTestCase { /// 自动修图配置 ViewModel 测试。 @MainActor final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase { + func testModeToggleRetainsDraftButDisabledSubmissionOmitsTemplate() async { + let api = TravelAlbumMockAPI() + api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(refinedTemplates: [ + TravelAlbumAIRetouchTemplate(id: 12, name: "清透", previewURL: ""), + ]) + let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true) + await viewModel.loadTemplates(api: api) + viewModel.selectMode(enabled: true) + XCTAssertFalse(viewModel.canConfirm) + viewModel.selectTemplate(id: 12) + viewModel.selectMode(enabled: false) + XCTAssertEqual(viewModel.pendingConfiguration, .disabled) + XCTAssertEqual(viewModel.selectedTemplateId, 12) + viewModel.selectMode(enabled: true) + XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12) + XCTAssertEqual(viewModel.selectedTemplateName, "清透") + XCTAssertTrue(viewModel.canConfirm) + } + + func testOriginalModeRemainsConfirmableDuringTemplateLoadingAndFailure() async { + let api = TravelAlbumMockAPI() + api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用") + let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true) + var couldConfirmWhileLoading = false + viewModel.onStateChange = { + if viewModel.isLoading { couldConfirmWhileLoading = viewModel.canConfirm } + } + await viewModel.loadTemplates(api: api) + viewModel.onStateChange = nil + XCTAssertTrue(couldConfirmWhileLoading) + XCTAssertTrue(viewModel.canConfirm) + XCTAssertEqual(viewModel.pendingConfiguration, .disabled) + viewModel.selectMode(enabled: true) + XCTAssertFalse(viewModel.canConfirm) + } + + func testTemplateOnlyEntryRequiresAISelectionAndCannotDisable() { + let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: false) + XCTAssertTrue(viewModel.isEnabled) + XCTAssertFalse(viewModel.canConfirm) + XCTAssertNil(viewModel.pendingConfiguration) + viewModel.selectMode(enabled: false) + XCTAssertTrue(viewModel.isEnabled) + } + func testLoadsOnlyRefinedTemplatesAndKeepsSingleSelection() async { let api = TravelAlbumMockAPI() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( @@ -470,7 +515,7 @@ final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase { let viewModel = TravelAlbumAutoRetouchSettingViewModel( scenicId: 18, configuration: .disabled, - startsWithModeSelection: true + allowsModeSelection: true ) viewModel.selectMode(enabled: true) @@ -490,7 +535,7 @@ final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase { let viewModel = TravelAlbumAutoRetouchSettingViewModel( scenicId: 18, configuration: TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12), - startsWithModeSelection: false + allowsModeSelection: false ) await viewModel.loadTemplates(api: api)