diff --git a/suixinkan/Assets.xcassets/travel_album_before_after.imageset/Contents.json b/suixinkan/Assets.xcassets/travel_album_before_after.imageset/Contents.json new file mode 100644 index 0000000..7dca1fd --- /dev/null +++ b/suixinkan/Assets.xcassets/travel_album_before_after.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "travel_album_before_after.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/travel_album_before_after.imageset/travel_album_before_after.svg b/suixinkan/Assets.xcassets/travel_album_before_after.imageset/travel_album_before_after.svg new file mode 100644 index 0000000..bccac8a --- /dev/null +++ b/suixinkan/Assets.xcassets/travel_album_before_after.imageset/travel_album_before_after.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/suixinkan/Features/TravelAlbum/AI修图接口文档.md b/suixinkan/Features/TravelAlbum/AI修图接口文档.md index f80fb8f..4b19ffb 100644 --- a/suixinkan/Features/TravelAlbum/AI修图接口文档.md +++ b/suixinkan/Features/TravelAlbum/AI修图接口文档.md @@ -117,6 +117,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave "id": "tpl_refined_12", "name": "清透精修", "preview_url": "https://cdn.example.com/templates/refined_12.jpg", + "before_url": "https://cdn.example.com/templates/refined_12_before.jpg", + "after_url": "https://cdn.example.com/templates/refined_12_after.jpg", "enabled": true } ] @@ -135,6 +137,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave "id": "tpl_atmosphere_06", "name": "暖阳", "preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg", + "before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg", + "after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg", "enabled": true } ] @@ -153,6 +157,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave "id": "tpl_cover_03", "name": "旅行画册", "preview_url": "https://cdn.example.com/templates/cover_03.jpg", + "before_url": "https://cdn.example.com/templates/cover_03_before.jpg", + "after_url": "https://cdn.example.com/templates/cover_03_after.jpg", "enabled": true } ] @@ -178,6 +184,7 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave - 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。 - 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。 +- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。 - 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。 - 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。 - 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。 diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index 576d488..ff9638f 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -403,23 +403,45 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable { } } -/// AI 修图模板,包含业务 ID、展示名称和预览图地址。 +/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。 struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable { let id: Int let name: String let previewURL: String + let beforeURL: String + let afterURL: String enum CodingKeys: String, CodingKey { case id case name case previewURL = "preview_url" + case beforeURL = "before_url" + case afterURL = "after_url" } /// 创建 AI 修图模板。 - init(id: Int, name: String, previewURL: String) { + init( + id: Int, + name: String, + previewURL: String, + beforeURL: String = "", + afterURL: String = "" + ) { self.id = id self.name = name self.previewURL = previewURL + self.beforeURL = beforeURL + self.afterURL = afterURL + } + + /// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + previewURL = try container.decode(String.self, forKey: .previewURL) + beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? "" + afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? "" } } diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift index 1e43ee8..32d5024 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift @@ -5,6 +5,32 @@ import Foundation +/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。 +struct BeforeAfterComparisonContent: Sendable, Equatable { + let beforeURL: String + let afterURL: String + let beforeLabel: String + let afterLabel: String + + /// 创建前后对比内容,默认使用 AI 修图模块的中文角标。 + init( + beforeURL: String, + afterURL: String, + beforeLabel: String = "原图", + afterLabel: String = "效果图" + ) { + self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines) + self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines) + self.beforeLabel = beforeLabel + self.afterLabel = afterLabel + } + + /// 两张图片地址均存在时才允许进入对比页。 + var isValid: Bool { + !beforeURL.isEmpty && !afterURL.isEmpty + } +} + /// 相册预览页横向滑动策略,由调用方通过配置注入。 enum TravelAlbumPreviewSwipeMode: Sendable, Equatable { /// 横滑只切换原图项目,关联图通过 Tab 切换。 @@ -85,6 +111,19 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { assets.first { $0.kind == kind } } + /// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。 + func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? { + guard kind == .retouched || kind == .atmosphere, + let original = asset(for: .original), + let result = asset(for: kind) + else { return nil } + let content = BeforeAfterComparisonContent( + beforeURL: original.displayURL, + afterURL: result.displayURL + ) + return content.isValid ? content : nil + } + /// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。 init(material: TravelAlbumMaterial) { originalMaterialId = material.id @@ -157,6 +196,14 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { } } +extension TravelAlbumAIRetouchTemplate { + /// 使用接口返回的模板示例前后图构建对比页内容。 + var comparisonContent: BeforeAfterComparisonContent? { + let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL) + return content.isValid ? content : nil + } +} + /// 预览分页节点,记录当前图片所属项目及类型。 struct TravelAlbumPreviewNode: Sendable, Hashable { let projectIndex: Int diff --git a/suixinkan/Features/TravelAlbum/ViewModels/BeforeAfterComparisonViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/BeforeAfterComparisonViewModel.swift new file mode 100644 index 0000000..c598048 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/BeforeAfterComparisonViewModel.swift @@ -0,0 +1,79 @@ +// +// BeforeAfterComparisonViewModel.swift +// suixinkan +// + +import Foundation + +/// 前后对比分隔线的共享运动参数与计算规则。 +enum BeforeAfterComparisonMotion { + static let automaticCenter: CGFloat = 0.5 + static let automaticAmplitude: CGFloat = 0.3 + static let automaticSpeed: Double = 0.8 + static let manualRange: ClosedRange = 0.05 ... 0.95 + + /// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。 + static func automaticFraction(elapsed: TimeInterval) -> CGFloat { + automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude + } + + /// 将手动拖动位置限制在两侧 5% 的安全边界内。 + static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat { + min(manualRange.upperBound, max(manualRange.lowerBound, fraction)) + } + + /// 计算从当前位置恢复正向自动运动所需的正弦相位时间。 + static func phaseTime(for fraction: CGFloat) -> TimeInterval { + let normalized = Double( + min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude)) + ) + return asin(normalized) / automaticSpeed + } +} + +/// 前后对比图片在可用区域内完整展示时的等比布局计算。 +enum BeforeAfterComparisonLayout { + static let fallbackAspectRatio: CGFloat = 3.0 / 4.0 + + /// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。 + static func aspectRatio(for imageSize: CGSize) -> CGFloat { + guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio } + let ratio = imageSize.width / imageSize.height + return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio + } + + /// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。 + static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize { + guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero } + let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio + if maximumSize.width / maximumSize.height > ratio { + return CGSize(width: maximumSize.height * ratio, height: maximumSize.height) + } + return CGSize(width: maximumSize.width, height: maximumSize.width / ratio) + } +} + +/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。 +final class BeforeAfterComparisonViewModel { + let content: BeforeAfterComparisonContent + + /// 创建指定内容的前后对比页状态。 + init(content: BeforeAfterComparisonContent) { + self.content = content + } + + /// 原图远程地址。 + var beforeImageURL: URL? { + URL(string: content.beforeURL) + } + + /// 效果图远程地址。 + var afterImageURL: URL? { + URL(string: content.afterURL) + } + + /// 两张图片地址都可供页面加载时返回 true。 + var isValid: Bool { + content.isValid && beforeImageURL != nil && afterImageURL != nil + } +} diff --git a/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift b/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift new file mode 100644 index 0000000..321f770 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift @@ -0,0 +1,520 @@ +// +// BeforeAfterComparisonViewController.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// 暗色全屏前后图片对比页,通过可拖拽分隔线查看原图与效果图。 +final class BeforeAfterComparisonViewController: UIViewController { + private let viewModel: BeforeAfterComparisonViewModel + private let backButton = UIButton(type: .system) + private let comparisonContainer = UIView() + private let afterImageView = UIImageView() + private let beforeImageView = UIImageView() + private let beforeRevealMask = CAShapeLayer() + private let dividerLine = UIView() + private let dividerHandle = BeforeAfterComparisonHandleView() + private let beforeLabel = BeforeAfterComparisonLabel() + private let afterLabel = BeforeAfterComparisonLabel() + private let statusContainer = UIView() + private let activityIndicator = UIActivityIndicatorView(style: .large) + private let statusLabel = UILabel() + private let retryButton = UIButton(type: .system) + + private var dividerFraction: CGFloat = 0.5 + private var loadGeneration = 0 + private var beforeLoaded = false + private var afterLoaded = false + private var isReady = false + private var displayLink: CADisplayLink? + private var animationStartTime: CFTimeInterval? + private var isDraggingDivider = false + private var imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio + private var beforeImageSize: CGSize? + private var afterImageSize: CGSize? + + /// 创建指定前后图片内容的全屏对比页。 + init(viewModel: BeforeAfterComparisonViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + bindActions() + loadImages() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutComparisonContainer() + layoutDivider() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + startAutomaticMotion(from: dividerFraction) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopAutomaticMotion() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + displayLink?.invalidate() + displayLink = nil + } + + deinit { + displayLink?.invalidate() + NotificationCenter.default.removeObserver(self) + } + + private func setupUI() { + view.backgroundColor = UIColor(hex: 0x05070D) + view.accessibilityIdentifier = "travelAlbum.beforeAfterComparison" + + var backConfiguration = UIButton.Configuration.filled() + backConfiguration.image = UIImage(systemName: "chevron.left") + backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( + pointSize: 19, + weight: .semibold + ) + backConfiguration.baseForegroundColor = .white + backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12) + backConfiguration.background.cornerRadius = 24 + backConfiguration.contentInsets = .zero + backButton.configuration = backConfiguration + backButton.accessibilityLabel = "返回" + backButton.accessibilityIdentifier = "travelAlbum.beforeAfterBackButton" + + comparisonContainer.backgroundColor = UIColor(hex: 0x111827) + comparisonContainer.layer.cornerRadius = 0 + comparisonContainer.layer.borderColor = UIColor(hex: 0x263244).cgColor + comparisonContainer.layer.borderWidth = 1 + comparisonContainer.clipsToBounds = true + comparisonContainer.accessibilityIdentifier = "travelAlbum.beforeAfterImageContainer" + + [afterImageView, beforeImageView].forEach { imageView in + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.backgroundColor = UIColor(hex: 0x111827) + imageView.isHidden = true + } + afterImageView.accessibilityLabel = viewModel.content.afterLabel + beforeImageView.accessibilityLabel = viewModel.content.beforeLabel + beforeImageView.layer.mask = beforeRevealMask + + dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.72) + dividerLine.layer.shadowColor = UIColor.black.cgColor + dividerLine.layer.shadowOpacity = 0.35 + dividerLine.layer.shadowRadius = 2 + dividerLine.layer.shadowOffset = .zero + dividerLine.isHidden = true + dividerLine.isUserInteractionEnabled = false + dividerHandle.isHidden = true + dividerHandle.accessibilityIdentifier = "travelAlbum.beforeAfterDivider" + + beforeLabel.text = viewModel.content.beforeLabel + beforeLabel.accessibilityIdentifier = "travelAlbum.beforeLabel" + afterLabel.text = viewModel.content.afterLabel + afterLabel.accessibilityIdentifier = "travelAlbum.afterLabel" + beforeLabel.isHidden = true + afterLabel.isHidden = true + + statusContainer.backgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.86) + activityIndicator.color = .white + activityIndicator.hidesWhenStopped = true + statusLabel.textColor = UIColor.white.withAlphaComponent(0.78) + statusLabel.font = .systemFont(ofSize: 14, weight: .medium) + statusLabel.textAlignment = .center + statusLabel.numberOfLines = 0 + statusLabel.text = "正在加载对比图片" + statusLabel.accessibilityIdentifier = "travelAlbum.beforeAfterStatusLabel" + var retryConfiguration = UIButton.Configuration.filled() + retryConfiguration.title = "重试" + retryConfiguration.baseForegroundColor = .white + retryConfiguration.baseBackgroundColor = UIColor(hex: 0x1677FF) + retryConfiguration.background.cornerRadius = 12 + retryButton.configuration = retryConfiguration + retryButton.accessibilityIdentifier = "travelAlbum.beforeAfterRetryButton" + retryButton.isHidden = true + + view.addSubview(backButton) + view.addSubview(comparisonContainer) + comparisonContainer.addSubview(afterImageView) + comparisonContainer.addSubview(beforeImageView) + comparisonContainer.addSubview(dividerLine) + comparisonContainer.addSubview(dividerHandle) + comparisonContainer.addSubview(beforeLabel) + comparisonContainer.addSubview(afterLabel) + comparisonContainer.addSubview(statusContainer) + statusContainer.addSubview(activityIndicator) + statusContainer.addSubview(statusLabel) + statusContainer.addSubview(retryButton) + } + + private func setupConstraints() { + backButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(20) + make.leading.equalToSuperview().offset(18) + make.size.equalTo(48) + } + afterImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + beforeImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + beforeLabel.snp.makeConstraints { make in + make.leading.bottom.equalToSuperview().inset(10) + make.height.equalTo(26) + } + afterLabel.snp.makeConstraints { make in + make.trailing.bottom.equalToSuperview().inset(10) + make.height.equalTo(26) + } + statusContainer.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + activityIndicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-28) + } + statusLabel.snp.makeConstraints { make in + make.top.equalTo(activityIndicator.snp.bottom).offset(14) + make.leading.trailing.equalToSuperview().inset(32) + } + retryButton.snp.makeConstraints { make in + make.top.equalTo(statusLabel.snp.bottom).offset(14) + make.centerX.equalToSuperview() + make.width.equalTo(96) + make.height.equalTo(44) + } + } + + private func bindActions() { + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + let scrubberGesture = UILongPressGestureRecognizer(target: self, action: #selector(dividerDragged(_:))) + scrubberGesture.minimumPressDuration = 0 + scrubberGesture.allowableMovement = .greatestFiniteMagnitude + comparisonContainer.addGestureRecognizer(scrubberGesture) + dividerHandle.onIncrement = { [weak self] in self?.adjustDivider(by: 0.1) } + dividerHandle.onDecrement = { [weak self] in self?.adjustDivider(by: -0.1) } + NotificationCenter.default.addObserver( + self, + selector: #selector(reduceMotionStatusChanged), + name: UIAccessibility.reduceMotionStatusDidChangeNotification, + object: nil + ) + } + + private func loadImages() { + loadGeneration += 1 + let generation = loadGeneration + beforeImageView.kf.cancelDownloadTask() + afterImageView.kf.cancelDownloadTask() + beforeImageView.image = nil + afterImageView.image = nil + beforeLoaded = false + afterLoaded = false + beforeImageSize = nil + afterImageSize = nil + imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio + isReady = false + dividerFraction = BeforeAfterComparisonMotion.automaticCenter + stopAutomaticMotion() + setComparisonVisible(false) + statusContainer.isHidden = false + statusLabel.text = "正在加载对比图片" + retryButton.isHidden = true + activityIndicator.startAnimating() + + guard viewModel.isValid, + let beforeURL = viewModel.beforeImageURL, + let afterURL = viewModel.afterImageURL + else { + showLoadFailure() + return + } + + beforeImageView.kf.setImage(with: beforeURL) { [weak self] result in + self?.handleImageResult(result, isBefore: true, generation: generation) + } + afterImageView.kf.setImage(with: afterURL) { [weak self] result in + self?.handleImageResult(result, isBefore: false, generation: generation) + } + } + + private func handleImageResult( + _ result: Result, + isBefore: Bool, + generation: Int + ) { + guard generation == loadGeneration else { return } + switch result { + case let .success(value): + if isBefore { + beforeLoaded = true + beforeImageSize = value.image.size + } else { + afterLoaded = true + afterImageSize = value.image.size + } + guard beforeLoaded && afterLoaded else { return } + let resolvedSize = afterImageSize ?? beforeImageSize ?? .zero + imageAspectRatio = BeforeAfterComparisonLayout.aspectRatio(for: resolvedSize) + view.setNeedsLayout() + view.layoutIfNeeded() + isReady = true + activityIndicator.stopAnimating() + statusContainer.isHidden = true + setComparisonVisible(true) + layoutDivider() + startAutomaticMotion(from: dividerFraction) + UIAccessibility.post(notification: .announcement, argument: "对比图片已加载") + case .failure: + showLoadFailure() + } + } + + private func showLoadFailure() { + isReady = false + activityIndicator.stopAnimating() + statusContainer.isHidden = false + statusLabel.text = "图片加载失败,请重试" + retryButton.isHidden = false + setComparisonVisible(false) + UIAccessibility.post(notification: .announcement, argument: statusLabel.text) + } + + private func setComparisonVisible(_ visible: Bool) { + afterImageView.isHidden = !visible + beforeImageView.isHidden = !visible + dividerLine.isHidden = !visible + dividerHandle.isHidden = !visible + beforeLabel.isHidden = !visible + afterLabel.isHidden = !visible + } + + private func layoutComparisonContainer() { + let safeFrame = view.bounds.inset(by: view.safeAreaInsets) + let topBoundary = max(safeFrame.minY, backButton.frame.maxY + 24) + let bottomBoundary = safeFrame.maxY - 24 + let center = CGPoint(x: view.bounds.midX, y: safeFrame.midY) + let verticalRadius = max( + 1, + min(center.y - topBoundary, bottomBoundary - center.y) + ) + let maximumSize = CGSize( + width: max(1, view.bounds.width), + height: verticalRadius * 2 + ) + let fittedSize = BeforeAfterComparisonLayout.fittedSize( + aspectRatio: imageAspectRatio, + maximumSize: maximumSize + ) + comparisonContainer.frame = CGRect( + x: center.x - fittedSize.width / 2, + y: center.y - fittedSize.height / 2, + width: fittedSize.width, + height: fittedSize.height + ) + comparisonContainer.layoutIfNeeded() + } + + private func layoutDivider() { + guard comparisonContainer.bounds.width > 0 else { return } + let bounds = comparisonContainer.bounds + let x = bounds.width * dividerFraction + beforeRevealMask.frame = beforeImageView.bounds + beforeRevealMask.path = UIBezierPath( + rect: CGRect(x: 0, y: 0, width: x, height: bounds.height) + ).cgPath + dividerLine.frame = CGRect(x: x - 1.5, y: 0, width: 3, height: bounds.height) + dividerHandle.frame = CGRect(x: x - 24, y: bounds.midY - 24, width: 48, height: 48) + dividerHandle.accessibilityValue = "原图占比 (Int((dividerFraction * 100).rounded()))%" + } + + private func updateDivider(locationX: CGFloat) { + guard isReady, comparisonContainer.bounds.width > 0 else { return } + dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction( + locationX / comparisonContainer.bounds.width + ) + layoutDivider() + } + + private func adjustDivider(by delta: CGFloat) { + stopAutomaticMotion() + dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(dividerFraction + delta) + layoutDivider() + } + + @objc private func dividerDragged(_ gesture: UILongPressGestureRecognizer) { + switch gesture.state { + case .began: + isDraggingDivider = true + stopAutomaticMotion() + dividerHandle.setActive(true, animated: !UIAccessibility.isReduceMotionEnabled) + updateDivider(locationX: gesture.location(in: comparisonContainer).x) + case .changed: + updateDivider(locationX: gesture.location(in: comparisonContainer).x) + case .ended, .cancelled, .failed: + updateDivider(locationX: gesture.location(in: comparisonContainer).x) + isDraggingDivider = false + dividerHandle.setActive(false, animated: !UIAccessibility.isReduceMotionEnabled) + startAutomaticMotion(from: dividerFraction) + default: + break + } + } + + @objc private func automaticMotionTick(_ link: CADisplayLink) { + guard isReady, !isDraggingDivider, let animationStartTime else { return } + dividerFraction = BeforeAfterComparisonMotion.automaticFraction( + elapsed: link.timestamp - animationStartTime + ) + layoutDivider() + } + + private func startAutomaticMotion(from fraction: CGFloat) { + guard isReady, + viewIfLoaded?.window != nil, + !isDraggingDivider, + !UIAccessibility.isReduceMotionEnabled + else { return } + animationStartTime = CACurrentMediaTime() - BeforeAfterComparisonMotion.phaseTime(for: fraction) + if displayLink == nil { + let link = CADisplayLink(target: self, selector: #selector(automaticMotionTick(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60) + link.add(to: .main, forMode: .common) + displayLink = link + } + displayLink?.isPaused = false + } + + private func stopAutomaticMotion() { + displayLink?.isPaused = true + animationStartTime = nil + } + + @objc private func reduceMotionStatusChanged() { + if UIAccessibility.isReduceMotionEnabled { + stopAutomaticMotion() + } else { + startAutomaticMotion(from: dividerFraction) + } + } + + @objc private func backTapped() { + dismiss(animated: true) + } + + @objc private func retryTapped() { + loadImages() + } +} + +/// 前后对比分隔线的可调节圆形手柄。 +private final class BeforeAfterComparisonHandleView: UIView { + var onIncrement: (() -> Void)? + var onDecrement: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor.black.withAlphaComponent(0.38) + layer.cornerRadius = 24 + layer.borderColor = UIColor.white.withAlphaComponent(0.82).cgColor + layer.borderWidth = 2 + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.32 + layer.shadowRadius = 7 + layer.shadowOffset = CGSize(width: 0, height: 3) + isAccessibilityElement = true + accessibilityLabel = "前后对比分隔线" + accessibilityTraits = [.adjustable] + + let imageView = UIImageView(image: UIImage(systemName: "arrow.left.and.right")) + imageView.tintColor = .white + imageView.contentMode = .scaleAspectFit + addSubview(imageView) + imageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(21) + } + } + + /// 更新手柄按压态,为拖动接管提供轻微但不改变布局的视觉反馈。 + func setActive(_ active: Bool, animated: Bool) { + let changes = { + self.transform = active ? CGAffineTransform(scaleX: 1.08, y: 1.08) : .identity + self.backgroundColor = UIColor.black.withAlphaComponent(active ? 0.52 : 0.38) + } + guard animated else { + changes() + return + } + UIView.animate( + withDuration: 0.18, + delay: 0, + options: [.curveEaseOut, .beginFromCurrentState], + animations: changes + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func accessibilityIncrement() { + onIncrement?() + } + + override func accessibilityDecrement() { + onDecrement?() + } +} + +/// 前后对比图片底部的高对比度胶囊角标。 +private final class BeforeAfterComparisonLabel: UILabel { + override init(frame: CGRect) { + super.init(frame: frame) + textColor = .white + font = .systemFont(ofSize: 12, weight: .medium) + textAlignment = .center + backgroundColor = UIColor.black.withAlphaComponent(0.5) + layer.cornerRadius = 13 + layer.borderColor = UIColor.white.withAlphaComponent(0.2).cgColor + layer.borderWidth = 0.5 + clipsToBounds = true + setContentHuggingPriority(.required, for: .horizontal) + layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + 20, height: 26) + } +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift index 1eb6fea..4196bef 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift @@ -434,8 +434,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { } private func showPreview(for template: TravelAlbumAIRetouchTemplate) { - let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template) - controller.modalPresentationStyle = .fullScreen + guard let content = template.comparisonContent else { + showToast("暂无对比预览") + return + } + let controller = BeforeAfterComparisonViewController( + viewModel: BeforeAfterComparisonViewModel(content: content) + ) present(controller, animated: true) } } @@ -908,128 +913,6 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { } } -/// AI 修图模板全屏预览,支持双指缩放查看模板细节。 -final class TravelAlbumAIRetouchTemplatePreviewViewController: UIViewController { - private let template: TravelAlbumAIRetouchTemplate - private let backButton = UIButton(type: .system) - private let titleLabel = UILabel() - private let subtitleLabel = UILabel() - private let scrollView = UIScrollView() - private let imageView = UIImageView() - - /// 创建指定模板的全屏预览页。 - init(template: TravelAlbumAIRetouchTemplate) { - self.template = template - super.init(nibName: nil, bundle: nil) - modalPresentationStyle = .fullScreen - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupUI() - setupConstraints() - loadPreview() - } - - private func setupUI() { - view.backgroundColor = .black - view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreview" - - var backConfiguration = UIButton.Configuration.filled() - backConfiguration.image = UIImage(systemName: "chevron.left") - backConfiguration.baseForegroundColor = .white - backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12) - backConfiguration.background.cornerRadius = 24 - backButton.configuration = backConfiguration - backButton.accessibilityLabel = "返回模板选择" - backButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewBackButton" - backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) - - titleLabel.text = template.name - titleLabel.textColor = .white - titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) - titleLabel.textAlignment = .center - titleLabel.lineBreakMode = .byTruncatingTail - - subtitleLabel.text = "双指缩放查看细节" - subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.52) - subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular) - subtitleLabel.textAlignment = .center - - scrollView.minimumZoomScale = 1 - scrollView.maximumZoomScale = 4 - scrollView.delegate = self - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewScrollView" - imageView.contentMode = .scaleAspectFit - imageView.clipsToBounds = true - imageView.backgroundColor = UIColor(hex: 0x111111) - imageView.accessibilityLabel = "\(template.name)模板预览图" - - view.addSubview(backButton) - view.addSubview(titleLabel) - view.addSubview(subtitleLabel) - view.addSubview(scrollView) - scrollView.addSubview(imageView) - } - - private func setupConstraints() { - backButton.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(20) - make.leading.equalToSuperview().offset(18) - make.size.equalTo(48) - } - titleLabel.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(20) - make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12) - make.centerX.equalToSuperview() - make.trailing.lessThanOrEqualToSuperview().offset(-66) - make.height.equalTo(26) - } - subtitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(2) - make.centerX.equalToSuperview() - } - scrollView.snp.makeConstraints { make in - make.top.equalTo(subtitleLabel.snp.bottom).offset(18) - make.leading.trailing.bottom.equalToSuperview() - } - imageView.snp.makeConstraints { make in - make.center.equalTo(scrollView.frameLayoutGuide) - make.width.equalTo(scrollView.frameLayoutGuide) - make.height.equalTo(imageView.snp.width).multipliedBy(0.75) - } - } - - private func loadPreview() { - let placeholder = UIImage(named: "ai_retouch_template_placeholder") - guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else { - imageView.image = placeholder - return - } - imageView.kf.setImage( - with: url, - placeholder: placeholder - ) - } - - @objc private func backTapped() { - dismiss(animated: true) - } -} - -extension TravelAlbumAIRetouchTemplatePreviewViewController: UIScrollViewDelegate { - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - imageView - } -} - /// AI 修图模板页视觉常量。 private enum AIRetouchTemplateStyle { static let primary = UIColor(hex: 0x1677FF) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift index 27956ed..51658fb 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift @@ -48,6 +48,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { private let variantControls = UIView() private let variantSegmentedControl = UISegmentedControl() private let highResolutionButton = UIButton(type: .system) + private let comparisonButton = UIButton(type: .system) private let actionStack = UIStackView() private let deleteButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system) @@ -193,6 +194,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { ) actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack" configureHighResolutionButton() + configureComparisonButton() configureActions() let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel]) @@ -212,6 +214,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { variantControls.addSubview(variantSegmentedControl) variantControls.addSubview(highResolutionButton) bottomChrome.addSubview(actionStack) + view.addSubview(comparisonButton) topChrome.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() @@ -274,6 +277,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { make.height.equalTo(70) make.bottom.equalTo(view.safeAreaLayoutGuide) } + comparisonButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(bottomChrome.snp.top).offset(-12) + make.size.equalTo(48) + } } private func configureHighResolutionButton() { @@ -293,6 +301,27 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside) } + private func configureComparisonButton() { + var configuration = UIButton.Configuration.filled() + configuration.image = UIImage(named: "travel_album_before_after")?.withRenderingMode(.alwaysTemplate) + configuration.contentInsets = .zero + configuration.baseForegroundColor = .white + configuration.baseBackgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.94) + configuration.background.cornerRadius = 24 + configuration.background.strokeColor = UIColor(hex: 0x263244) + configuration.background.strokeWidth = 1 + comparisonButton.configuration = configuration + comparisonButton.accessibilityLabel = "前后对比" + comparisonButton.accessibilityHint = "对比当前效果图与原图" + comparisonButton.accessibilityIdentifier = "travelAlbum.previewComparisonButton" + comparisonButton.layer.shadowColor = UIColor.black.cgColor + comparisonButton.layer.shadowOpacity = 0.28 + comparisonButton.layer.shadowRadius = 8 + comparisonButton.layer.shadowOffset = CGSize(width: 0, height: 3) + comparisonButton.isHidden = true + comparisonButton.addTarget(self, action: #selector(comparisonTapped), for: .touchUpInside) + } + private func configureActions() { let aiButton = makeActionButton( title: "AI修图", @@ -392,9 +421,21 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { highResolutionButton.isEnabled = asset != nil highResolutionButton.alpha = asset == nil ? 0.45 : 1 rebuildTabs() + updateComparisonButton() loadMoreIfNeeded(projectIndex: node.projectIndex) } + private func updateComparisonButton() { + let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil + let shouldShow = chromeVisible && hasComparison + comparisonButton.isHidden = !hasComparison + comparisonButton.isUserInteractionEnabled = shouldShow + comparisonButton.alpha = shouldShow ? 1 : 0 + comparisonButton.transform = shouldShow + ? .identity + : CGAffineTransform(translationX: 0, y: 10) + } + private func rebuildTabs() { while variantSegmentedControl.numberOfSegments > 0 { variantSegmentedControl.removeSegment(at: 0, animated: false) @@ -581,6 +622,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { private func toggleChrome() { chromeVisible.toggle() + let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil + if chromeVisible && hasComparison { + comparisonButton.isHidden = false + } + comparisonButton.isUserInteractionEnabled = chromeVisible && hasComparison let reduceMotion = UIAccessibility.isReduceMotionEnabled let animations = { self.topChrome.alpha = self.chromeVisible ? 1 : 0 @@ -591,12 +637,20 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { self.bottomChrome.transform = self.chromeVisible ? .identity : CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height) + self.comparisonButton.alpha = self.chromeVisible && hasComparison ? 1 : 0 + self.comparisonButton.transform = self.chromeVisible && hasComparison + ? .identity + : CGAffineTransform(translationX: 0, y: 10) } UIView.animate( withDuration: reduceMotion ? 0.12 : 0.22, delay: 0, options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState], - animations: animations + animations: animations, + completion: { [weak self] _ in + guard let self else { return } + self.comparisonButton.isHidden = !self.chromeVisible || !hasComparison + } ) } @@ -632,6 +686,17 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { toggleChrome() } + @objc private func comparisonTapped() { + guard presentedViewController == nil, + let project = currentProject, + let content = project.comparisonContent(for: currentAsset?.kind ?? .original) + else { return } + let controller = BeforeAfterComparisonViewController( + viewModel: BeforeAfterComparisonViewModel(content: content) + ) + present(controller, animated: true) + } + @objc private func aiTapped() { guard presentedViewController == nil else { return } guard let project = currentProject else { return } diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index 482784e..c6a85c4 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -162,7 +162,7 @@ final class TravelAlbumAPITests: XCTestCase { func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws { let data = envelopeJSON( - #"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}],"remaining_quota":12}"# + #"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg","before_url":"https://cdn/refined-before.jpg","after_url":"https://cdn/refined-after.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg","before_url":"https://cdn/atmosphere-before.jpg","after_url":"https://cdn/atmosphere-after.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg","before_url":"https://cdn/cover-before.jpg","after_url":"https://cdn/cover-after.jpg"}],"remaining_quota":12}"# ) let session = MockURLSession(responses: [data]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) @@ -170,6 +170,8 @@ final class TravelAlbumAPITests: XCTestCase { let response = try await api.aiRetouchTemplates(scenicId: 18) XCTAssertEqual(response.refinedTemplates.first?.name, "清透") + XCTAssertEqual(response.refinedTemplates.first?.beforeURL, "https://cdn/refined-before.jpg") + XCTAssertEqual(response.refinedTemplates.first?.afterURL, "https://cdn/refined-after.jpg") XCTAssertEqual(response.atmosphereTemplates.first?.id, 2) XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg") XCTAssertEqual(response.remainingQuota, 12) diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index 82ee432..d7f9b1f 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -637,35 +637,96 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(cell.accessibilityValue, "未选择") } - func testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws { - let template = TravelAlbumAIRetouchTemplate( - id: 2, - name: "简约留白", - previewURL: "" + func testBeforeAfterComparisonUsesImmersiveAccessibleLayout() throws { + let controller = BeforeAfterComparisonViewController( + viewModel: BeforeAfterComparisonViewModel( + content: BeforeAfterComparisonContent( + beforeURL: "https://cdn.example.com/before.jpg", + afterURL: "https://cdn.example.com/after.jpg" + ) + ) ) - let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() - let scrollView = try XCTUnwrap( + let imageContainer = try XCTUnwrap( controller.view.findSubview { - $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView" - } as? UIScrollView + $0.accessibilityIdentifier == "travelAlbum.beforeAfterImageContainer" + } ) let backButton = try XCTUnwrap( controller.view.findSubview { - $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton" + $0.accessibilityIdentifier == "travelAlbum.beforeAfterBackButton" + } as? UIButton + ) + let divider = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.beforeAfterDivider" + } + ) + + XCTAssertEqual(controller.view.backgroundColor, UIColor(hex: 0x05070D)) + XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图" }) + XCTAssertTrue(controller.view.allLabels().contains { $0.text == "效果图" }) + XCTAssertEqual(imageContainer.layer.cornerRadius, 0) + XCTAssertTrue(divider.accessibilityTraits.contains(.adjustable)) + XCTAssertEqual(divider.accessibilityValue, "原图占比 50%") + XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44) + XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44) + } + + func testPreviewComparisonButtonOnlyShowsForSelectedAIResult() throws { + UIView.setAnimationsEnabled(false) + defer { UIView.setAnimationsEnabled(true) } + let project = TravelAlbumPreviewProject( + originalMaterialId: 7, + assets: [ + TravelAlbumPreviewAsset( + id: "original-7", + kind: .original, + fileURL: "https://cdn.example.com/original.jpg", + coverURL: "", + fileName: "原图.jpg", + fileSize: 10 + ), + TravelAlbumPreviewAsset( + id: "retouched-7", + kind: .retouched, + fileURL: "https://cdn.example.com/retouched.jpg", + coverURL: "", + fileName: "精修.jpg", + fileSize: 10 + ), + ] + ) + let controller = TravelAlbumPhotoPreviewViewController( + projects: [project], + totalCount: 1, + startProjectIndex: 0 + ) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.layoutIfNeeded() + let segmentedControl = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl" + } as? UISegmentedControl + ) + let comparisonButton = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.previewComparisonButton" } as? UIButton ) - XCTAssertEqual(controller.view.backgroundColor, .black) - XCTAssertTrue(controller.view.allLabels().contains { $0.text == "简约留白" }) - XCTAssertTrue(controller.view.allLabels().contains { $0.text == "双指缩放查看细节" }) - XCTAssertEqual(scrollView.minimumZoomScale, 1) - XCTAssertEqual(scrollView.maximumZoomScale, 4) - XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44) - XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44) + XCTAssertTrue(comparisonButton.isHidden) + segmentedControl.selectedSegmentIndex = 1 + segmentedControl.sendActions(for: .valueChanged) + + XCTAssertFalse(comparisonButton.isHidden) + XCTAssertNil(comparisonButton.configuration?.title) + XCTAssertNotNil(comparisonButton.configuration?.image) + XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48)) } func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws { diff --git a/suixinkanTests/TravelAlbumModelsTests.swift b/suixinkanTests/TravelAlbumModelsTests.swift index 2b6f485..aea046b 100644 --- a/suixinkanTests/TravelAlbumModelsTests.swift +++ b/suixinkanTests/TravelAlbumModelsTests.swift @@ -8,6 +8,40 @@ import XCTest /// 旅拍相册模型与展示工具测试。 final class TravelAlbumModelsTests: XCTestCase { + func testBeforeAfterComparisonMotionMatchesShipSwiftInteractionRange() { + XCTAssertEqual(BeforeAfterComparisonMotion.automaticFraction(elapsed: 0), 0.5, accuracy: 0.001) + XCTAssertEqual( + BeforeAfterComparisonMotion.automaticFraction( + elapsed: .pi / (2 * BeforeAfterComparisonMotion.automaticSpeed) + ), + 0.8, + accuracy: 0.001 + ) + XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0), 0.05) + XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(1), 0.95) + XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0.42), 0.42) + } + + func testBeforeAfterComparisonLayoutFitsVariableRatiosWithoutCropping() { + let landscape = BeforeAfterComparisonLayout.fittedSize( + aspectRatio: 16.0 / 9.0, + maximumSize: CGSize(width: 358, height: 580) + ) + let portrait = BeforeAfterComparisonLayout.fittedSize( + aspectRatio: 2.0 / 3.0, + maximumSize: CGSize(width: 358, height: 580) + ) + + XCTAssertEqual(landscape.width, 358, accuracy: 0.001) + XCTAssertEqual(landscape.height, 201.375, accuracy: 0.001) + XCTAssertEqual(portrait.width, 358, accuracy: 0.001) + XCTAssertEqual(portrait.height, 537, accuracy: 0.001) + XCTAssertEqual( + BeforeAfterComparisonLayout.aspectRatio(for: CGSize(width: 4000, height: 3000)), + 4.0 / 3.0 + ) + } + func testPreviewProjectMapsMaterialToOriginalAsset() { let material = TravelAlbumMaterial( id: 8, @@ -68,6 +102,77 @@ final class TravelAlbumModelsTests: XCTestCase { ) } + func testPreviewProjectBuildsComparisonOnlyForValidAIResults() { + let project = makePreviewProject(id: 8, kinds: [.original, .retouched, .atmosphere, .cover]) + + XCTAssertEqual( + project.comparisonContent(for: .retouched), + BeforeAfterComparisonContent( + beforeURL: "https://cdn.example.com/8-0.jpg", + afterURL: "https://cdn.example.com/8-1.jpg" + ) + ) + XCTAssertNotNil(project.comparisonContent(for: .atmosphere)) + XCTAssertNil(project.comparisonContent(for: .original)) + XCTAssertNil(project.comparisonContent(for: .cover)) + + let missingResultURL = TravelAlbumPreviewProject( + originalMaterialId: 9, + assets: [ + TravelAlbumPreviewAsset( + id: "original-9", + kind: .original, + fileURL: "https://cdn.example.com/original.jpg", + coverURL: "", + fileName: "原图.jpg", + fileSize: 1 + ), + TravelAlbumPreviewAsset( + id: "retouched-9", + kind: .retouched, + fileURL: " ", + coverURL: "", + fileName: "精修.jpg", + fileSize: 1 + ), + ] + ) + XCTAssertNil(missingResultURL.comparisonContent(for: .retouched)) + } + + func testTemplateComparisonContentRequiresBothURLs() { + let valid = TravelAlbumAIRetouchTemplate( + id: 1, + name: "清透", + previewURL: "https://cdn.example.com/preview.jpg", + beforeURL: " https://cdn.example.com/before.jpg ", + afterURL: "https://cdn.example.com/after.jpg" + ) + let missingAfter = TravelAlbumAIRetouchTemplate( + id: 2, + name: "暖阳", + previewURL: "https://cdn.example.com/preview.jpg", + beforeURL: "https://cdn.example.com/before.jpg" + ) + + XCTAssertEqual(valid.comparisonContent?.beforeURL, "https://cdn.example.com/before.jpg") + XCTAssertEqual(valid.comparisonContent?.afterLabel, "效果图") + XCTAssertNil(missingAfter.comparisonContent) + } + + func testTemplateDecodingDefaultsMissingComparisonURLsToEmptyStrings() throws { + let data = Data( + #"{"id":3,"name":"旧模板","preview_url":"https://cdn.example.com/preview.jpg"}"#.utf8 + ) + + let template = try JSONDecoder().decode(TravelAlbumAIRetouchTemplate.self, from: data) + + XCTAssertEqual(template.previewURL, "https://cdn.example.com/preview.jpg") + XCTAssertEqual(template.beforeURL, "") + XCTAssertEqual(template.afterURL, "") + XCTAssertNil(template.comparisonContent) + } + func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() { let missingOriginal = TravelAlbumPreviewAsset( id: "cover-only",