fix: 代码审查 P2 建议项修复(22 项体验优化)

EditorView (8 项):
- 时长警告图标区分:分享警告改用 square.and.arrow.up
- coverExtractionTask 竞态防护:新增 isViewActive 守卫
- sensoryFeedback 优化:缩放触觉仅在手势结束时触发
- iPad 右侧面板增加水平内边距
- 预设列表/兼容模式/AI 区域硬编码间距替换为 DesignTokens
- 诊断按钮 padding 替换为 DesignTokens
- generateButton 补充 accessibilityLabel

PresetManager + RecentWorksManager (5 项):
- iCloud 合并回写 + 防循环标志位
- iCloud 配额防护(>900KB 跳过写入)
- QuotaViolation/AccountChange 事件处理
- EditingPreset 自定义 Codable(decodeIfPresent + 默认值)
- RecentWork aspectRatio 枚举化
- 清理 saveToLocalOnly 死代码

ResultView + ProcessingView + HomeView (5 项):
- ResultView animateIn 改用 structured concurrency
- ProcessingView 阶段数提取为常量
- ProcessingView 脉冲动画去重
- HomeView 删除触觉升级为 .warning
- HomeView Cancel 按钮本地化

LivePhotoCore + AppState (4 项):
- coverImageURL 参数去重,内部从 exportParams 读取
- progress 回调钳位 min(pct, 1.0)
- CropRect 新增 clamped() 归一化校验
- AppState 初始化错误增加 os.Logger 日志

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-08 00:34:44 +08:00
parent f3bcaf4651
commit c826689ee4
8 changed files with 146 additions and 80 deletions

View File

@@ -82,13 +82,23 @@ public struct CropRect: Codable, Sendable, Hashable {
/// ///
public static let full = CropRect() public static let full = CropRect()
/// [0, 1] CropRect x+width <= 1, y+height <= 1
public func clamped() -> CropRect {
let clampedX = min(max(x, 0), 1)
let clampedY = min(max(y, 0), 1)
let clampedW = min(max(width, 0), 1 - clampedX)
let clampedH = min(max(height, 0), 1 - clampedY)
return CropRect(x: clampedX, y: clampedY, width: clampedW, height: clampedH)
}
/// ///
public func toPixelRect(videoSize: CGSize) -> CGRect { public func toPixelRect(videoSize: CGSize) -> CGRect {
CGRect( let safe = clamped()
x: x * videoSize.width, return CGRect(
y: y * videoSize.height, x: safe.x * videoSize.width,
width: width * videoSize.width, y: safe.y * videoSize.height,
height: height * videoSize.height width: safe.width * videoSize.width,
height: safe.height * videoSize.height
) )
} }
} }
@@ -521,10 +531,10 @@ public actor LivePhotoBuilder {
public func buildResources( public func buildResources(
workId: UUID = UUID(), workId: UUID = UUID(),
sourceVideoURL: URL, sourceVideoURL: URL,
coverImageURL: URL? = nil,
exportParams: ExportParams = ExportParams(), exportParams: ExportParams = ExportParams(),
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
) async throws -> LivePhotoBuildOutput { ) async throws -> LivePhotoBuildOutput {
let coverImageURL = exportParams.coverImageURL
let assetIdentifier = UUID().uuidString let assetIdentifier = UUID().uuidString
let paths = try cacheManager.makeWorkPaths(workId: workId) let paths = try cacheManager.makeWorkPaths(workId: workId)
@@ -1070,7 +1080,7 @@ public actor LivePhotoBuilder {
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() { if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
currentFrameCount += 1 currentFrameCount += 1
let pct = Double(currentFrameCount) / Double(frameCount) let pct = Double(currentFrameCount) / Double(frameCount)
progress(pct) progress(min(pct, 1.0))
videoWriterInput.append(sampleBuffer) videoWriterInput.append(sampleBuffer)
} else { } else {
videoWriterInput.markAsFinished() videoWriterInput.markAsFinished()
@@ -1192,14 +1202,12 @@ public actor LivePhotoWorkflow {
public func buildSaveValidate( public func buildSaveValidate(
workId: UUID = UUID(), workId: UUID = UUID(),
sourceVideoURL: URL, sourceVideoURL: URL,
coverImageURL: URL? = nil,
exportParams: ExportParams = ExportParams(), exportParams: ExportParams = ExportParams(),
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
) async throws -> LivePhotoWorkflowResult { ) async throws -> LivePhotoWorkflowResult {
let output = try await builder.buildResources( let output = try await builder.buildResources(
workId: workId, workId: workId,
sourceVideoURL: sourceVideoURL, sourceVideoURL: sourceVideoURL,
coverImageURL: coverImageURL,
exportParams: exportParams, exportParams: exportParams,
progress: progress progress: progress
) )

View File

@@ -5,6 +5,7 @@
// App + // App +
// //
import os
import SwiftUI import SwiftUI
import PhotosUI import PhotosUI
import LivePhotoCore import LivePhotoCore
@@ -34,11 +35,13 @@ final class AppState {
private var workflow: LivePhotoWorkflow? private var workflow: LivePhotoWorkflow?
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>? private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
private var currentWorkId: UUID? private var currentWorkId: UUID?
private let logger = Logger(subsystem: "ToLivePhoto", category: "AppState")
init() { init() {
do { do {
workflow = try LivePhotoWorkflow() workflow = try LivePhotoWorkflow()
} catch { } catch {
logger.error("Failed to init LivePhotoWorkflow: \(error.localizedDescription, privacy: .public)")
#if DEBUG #if DEBUG
print("Failed to init LivePhotoWorkflow: \(error)") print("Failed to init LivePhotoWorkflow: \(error)")
#endif #endif
@@ -117,7 +120,6 @@ final class AppState {
let result = try await workflow.buildSaveValidate( let result = try await workflow.buildSaveValidate(
workId: workId, workId: workId,
sourceVideoURL: videoURL, sourceVideoURL: videoURL,
coverImageURL: exportParams.coverImageURL,
exportParams: exportParams exportParams: exportParams
) { progress in ) { progress in
Task { @MainActor in Task { @MainActor in

View File

@@ -18,6 +18,37 @@ struct EditingPreset: Codable, Identifiable, Hashable {
let trimDuration: Double let trimDuration: Double
let aiEnhance: Bool let aiEnhance: Bool
let compatibilityMode: Bool let compatibilityMode: Bool
/// 使 decodeIfPresent +
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
aspectRatio = try container.decodeIfPresent(AspectRatioTemplate.self, forKey: .aspectRatio) ?? .original
trimDuration = try container.decodeIfPresent(Double.self, forKey: .trimDuration) ?? 3.0
aiEnhance = try container.decodeIfPresent(Bool.self, forKey: .aiEnhance) ?? false
compatibilityMode = try container.decodeIfPresent(Bool.self, forKey: .compatibilityMode) ?? false
}
/// memberwise init 使
init(
id: UUID,
name: String,
createdAt: Date,
aspectRatio: AspectRatioTemplate,
trimDuration: Double,
aiEnhance: Bool,
compatibilityMode: Bool
) {
self.id = id
self.name = name
self.createdAt = createdAt
self.aspectRatio = aspectRatio
self.trimDuration = trimDuration
self.aiEnhance = aiEnhance
self.compatibilityMode = compatibilityMode
}
} }
/// UserDefaults + iCloud 10 /// UserDefaults + iCloud 10
@@ -31,6 +62,8 @@ final class PresetManager: ObservableObject {
private let storageKey = "editing_presets_v1" private let storageKey = "editing_presets_v1"
private let iCloudKey = "editing_presets_v1" private let iCloudKey = "editing_presets_v1"
private let iCloudStore = NSUbiquitousKeyValueStore.default private let iCloudStore = NSUbiquitousKeyValueStore.default
/// iCloud
private var isHandlingICloudChange = false
private init() { private init() {
loadFromStorage() loadFromStorage()
@@ -92,10 +125,19 @@ final class PresetManager: ObservableObject {
return return
} }
isHandlingICloudChange = true
defer { isHandlingICloudChange = false }
switch reason { switch reason {
case NSUbiquitousKeyValueStoreServerChange, case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange: NSUbiquitousKeyValueStoreInitialSyncChange:
mergeFromICloud() mergeFromICloud()
case NSUbiquitousKeyValueStoreQuotaViolationChange:
#if DEBUG
print("[PresetManager] iCloud quota violated — data may not sync")
#endif
case NSUbiquitousKeyValueStoreAccountChange:
mergeFromICloud()
default: default:
break break
} }
@@ -114,7 +156,7 @@ final class PresetManager: ObservableObject {
merged.sort { $0.createdAt > $1.createdAt } merged.sort { $0.createdAt > $1.createdAt }
presets = Array(merged.prefix(maxCount)) presets = Array(merged.prefix(maxCount))
saveToLocalOnly() persistToStorage()
} }
// MARK: - // MARK: -
@@ -138,6 +180,18 @@ final class PresetManager: ObservableObject {
do { do {
let data = try JSONEncoder().encode(presets) let data = try JSONEncoder().encode(presets)
UserDefaults.standard.set(data, forKey: storageKey) UserDefaults.standard.set(data, forKey: storageKey)
// true iCloud
guard !isHandlingICloudChange else { return }
// iCloud KVS key 1MB 100KB
if data.count > 900_000 {
#if DEBUG
print("[PresetManager] Data size \(data.count) exceeds iCloud safe limit, skipping iCloud write")
#endif
return
}
iCloudStore.set(data, forKey: iCloudKey) iCloudStore.set(data, forKey: iCloudKey)
} catch { } catch {
#if DEBUG #if DEBUG
@@ -145,15 +199,4 @@ final class PresetManager: ObservableObject {
#endif #endif
} }
} }
private func saveToLocalOnly() {
do {
let data = try JSONEncoder().encode(presets)
UserDefaults.standard.set(data, forKey: storageKey)
} catch {
#if DEBUG
print("[PresetManager] Failed to encode: \(error)")
#endif
}
}
} }

View File

@@ -7,6 +7,7 @@
import Combine import Combine
import Foundation import Foundation
import LivePhotoCore
import UIKit import UIKit
import Photos import Photos
@@ -15,18 +16,20 @@ struct RecentWork: Codable, Identifiable, Hashable {
let id: UUID let id: UUID
let createdAt: Date let createdAt: Date
let assetLocalIdentifier: String // PHAsset localIdentifier let assetLocalIdentifier: String // PHAsset localIdentifier
let aspectRatioRaw: String // AspectRatioTemplate.rawValue let aspectRatioRaw: String // AspectRatioTemplate.rawValue
let compatibilityMode: Bool let compatibilityMode: Bool
/// 访rawValue nil
var aspectRatio: AspectRatioTemplate? {
AspectRatioTemplate(rawValue: aspectRatioRaw)
}
/// 使 displayNamefallback raw string
var aspectRatioDisplayName: String { var aspectRatioDisplayName: String {
switch aspectRatioRaw { if let template = aspectRatio {
case "original": return String(localized: "aspectRatio.original") return template.displayName
case "lock_screen": return String(localized: "aspectRatio.lockScreen")
case "full_screen": return String(localized: "aspectRatio.fullScreen")
case "classic": return String(localized: "aspectRatio.classic")
case "square": return String(localized: "aspectRatio.square")
default: return aspectRatioRaw
} }
return aspectRatioRaw
} }
} }
@@ -41,6 +44,8 @@ final class RecentWorksManager: ObservableObject {
private let userDefaultsKey = "recent_works_v1" private let userDefaultsKey = "recent_works_v1"
private let iCloudKey = "recent_works_v1" private let iCloudKey = "recent_works_v1"
private let iCloudStore = NSUbiquitousKeyValueStore.default private let iCloudStore = NSUbiquitousKeyValueStore.default
/// iCloud
private var isHandlingICloudChange = false
private init() { private init() {
loadFromStorage() loadFromStorage()
@@ -130,10 +135,19 @@ final class RecentWorksManager: ObservableObject {
return return
} }
isHandlingICloudChange = true
defer { isHandlingICloudChange = false }
switch reason { switch reason {
case NSUbiquitousKeyValueStoreServerChange, case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange: NSUbiquitousKeyValueStoreInitialSyncChange:
mergeFromICloud() mergeFromICloud()
case NSUbiquitousKeyValueStoreQuotaViolationChange:
#if DEBUG
print("[RecentWorksManager] iCloud quota violated — data may not sync")
#endif
case NSUbiquitousKeyValueStoreAccountChange:
mergeFromICloud()
default: default:
break break
} }
@@ -152,7 +166,7 @@ final class RecentWorksManager: ObservableObject {
merged.sort { $0.createdAt > $1.createdAt } merged.sort { $0.createdAt > $1.createdAt }
recentWorks = Array(merged.prefix(maxCount)) recentWorks = Array(merged.prefix(maxCount))
saveToLocalOnly() saveToStorage()
} }
// MARK: - // MARK: -
@@ -176,18 +190,19 @@ final class RecentWorksManager: ObservableObject {
do { do {
let data = try JSONEncoder().encode(recentWorks) let data = try JSONEncoder().encode(recentWorks)
UserDefaults.standard.set(data, forKey: userDefaultsKey) UserDefaults.standard.set(data, forKey: userDefaultsKey)
iCloudStore.set(data, forKey: iCloudKey)
} catch {
#if DEBUG
print("[RecentWorksManager] Failed to encode: \(error)")
#endif
}
}
private func saveToLocalOnly() { // true iCloud
do { guard !isHandlingICloudChange else { return }
let data = try JSONEncoder().encode(recentWorks)
UserDefaults.standard.set(data, forKey: userDefaultsKey) // iCloud KVS key 1MB 100KB
if data.count > 900_000 {
#if DEBUG
print("[RecentWorksManager] Data size \(data.count) exceeds iCloud safe limit, skipping iCloud write")
#endif
return
}
iCloudStore.set(data, forKey: iCloudKey)
} catch { } catch {
#if DEBUG #if DEBUG
print("[RecentWorksManager] Failed to encode: \(error)") print("[RecentWorksManager] Failed to encode: \(error)")

View File

@@ -59,10 +59,14 @@ struct EditorView: View {
@State private var presetName = "" @State private var presetName = ""
@State private var showPresetPicker = false @State private var showPresetPicker = false
//
@State private var isViewActive = true
// //
@State private var generateTapCount: Int = 0 @State private var generateTapCount: Int = 0
@State private var presetSavedCount: Int = 0 @State private var presetSavedCount: Int = 0
@State private var coverImportCount: Int = 0 @State private var coverImportCount: Int = 0
@State private var cropScaleHapticCount: Int = 0
/// 使 iPad regular + /// 使 iPad regular +
private var useIPadLayout: Bool { private var useIPadLayout: Bool {
@@ -80,9 +84,11 @@ struct EditorView: View {
.navigationTitle(String(localized: "editor.title")) .navigationTitle(String(localized: "editor.title"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear { .onAppear {
isViewActive = true
loadVideo() loadVideo()
} }
.onDisappear { .onDisappear {
isViewActive = false
player?.pause() player?.pause()
coverExtractionTask?.cancel() coverExtractionTask?.cancel()
coverExtractionTask = nil coverExtractionTask = nil
@@ -90,7 +96,7 @@ struct EditorView: View {
} }
.sensoryFeedback(.selection, trigger: selectedAspectRatio) .sensoryFeedback(.selection, trigger: selectedAspectRatio)
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount) .sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
.sensoryFeedback(.impact(weight: .light), trigger: lastCropScale) .sensoryFeedback(.impact(weight: .light), trigger: cropScaleHapticCount)
.sensoryFeedback(.success, trigger: presetSavedCount) .sensoryFeedback(.success, trigger: presetSavedCount)
.sensoryFeedback(.selection, trigger: coverImportCount) .sensoryFeedback(.selection, trigger: coverImportCount)
} }
@@ -147,6 +153,7 @@ struct EditorView: View {
presetSection presetSection
generateButton generateButton
} }
.padding(.horizontal, DesignTokens.Spacing.lg)
.padding(.vertical, DesignTokens.Spacing.lg) .padding(.vertical, DesignTokens.Spacing.lg)
} }
.frame(minWidth: 320, maxWidth: 420) .frame(minWidth: 320, maxWidth: 420)
@@ -163,6 +170,7 @@ struct EditorView: View {
} }
.onEnded { value in .onEnded { value in
lastCropScale = max(1.0, min(3.0, lastCropScale * value)) lastCropScale = max(1.0, min(3.0, lastCropScale * value))
cropScaleHapticCount += 1
}, },
DragGesture() DragGesture()
.onChanged { value in .onChanged { value in
@@ -371,7 +379,7 @@ struct EditorView: View {
if trimEnd - trimStart > 3 { if trimEnd - trimStart > 3 {
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
Image(systemName: "exclamationmark.triangle") Image(systemName: "square.and.arrow.up")
.foregroundStyle(Color.accentOrange) .foregroundStyle(Color.accentOrange)
.font(.caption) .font(.caption)
Text(String(localized: "editor.durationShareWarning")) Text(String(localized: "editor.durationShareWarning"))
@@ -434,7 +442,7 @@ struct EditorView: View {
HStack { HStack {
Image(systemName: "wand.and.stars.inverse") Image(systemName: "wand.and.stars.inverse")
.foregroundStyle(Color.accentPurple) .foregroundStyle(Color.accentPurple)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(String(localized: "editor.aiEnhance")) Text(String(localized: "editor.aiEnhance"))
.font(.headline) .font(.headline)
Text(String(localized: "editor.aiEnhanceDescription")) Text(String(localized: "editor.aiEnhanceDescription"))
@@ -473,7 +481,7 @@ struct EditorView: View {
} }
if aiEnhanceEnabled && !aiModelDownloading { if aiEnhanceEnabled && !aiModelDownloading {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
if aiModelNeedsDownload { if aiModelNeedsDownload {
HStack(spacing: DesignTokens.Spacing.xs) { HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "arrow.down.circle") Image(systemName: "arrow.down.circle")
@@ -538,7 +546,7 @@ struct EditorView: View {
HStack { HStack {
Image(systemName: "gearshape.2") Image(systemName: "gearshape.2")
.foregroundStyle(.tint) .foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(String(localized: "editor.compatibilityMode")) Text(String(localized: "editor.compatibilityMode"))
.font(.headline) .font(.headline)
Text(String(localized: "editor.compatibilityDescription")) Text(String(localized: "editor.compatibilityDescription"))
@@ -550,7 +558,7 @@ struct EditorView: View {
.tint(.accentColor) .tint(.accentColor)
if compatibilityMode { if compatibilityMode {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
HStack(spacing: DesignTokens.Spacing.xs) { HStack(spacing: DesignTokens.Spacing.xs) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
@@ -630,7 +638,7 @@ struct EditorView: View {
.font(.caption) .font(.caption)
.fontWeight(.medium) .fontWeight(.medium)
} }
.padding(.top, 2) .padding(.top, DesignTokens.Spacing.xs)
} }
} }
} }
@@ -703,7 +711,7 @@ struct EditorView: View {
applyPreset(preset) applyPreset(preset)
showPresetPicker = false showPresetPicker = false
} label: { } label: {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
Text(preset.name) Text(preset.name)
.font(.body) .font(.body)
.foregroundStyle(.primary) .foregroundStyle(.primary)
@@ -754,6 +762,7 @@ struct EditorView: View {
) { ) {
startProcessing() startProcessing()
} }
.accessibilityLabel(String(localized: "editor.generateButton"))
.padding(.top, DesignTokens.Spacing.sm) .padding(.top, DesignTokens.Spacing.sm)
.padding(.bottom, DesignTokens.Spacing.sm) .padding(.bottom, DesignTokens.Spacing.sm)
} }
@@ -814,6 +823,7 @@ struct EditorView: View {
} }
await MainActor.run { await MainActor.run {
guard isViewActive else { return }
videoDuration = durationSeconds videoDuration = durationSeconds
trimEnd = max(0.1, min(1.0, durationSeconds)) trimEnd = max(0.1, min(1.0, durationSeconds))
keyFrameTime = trimEnd / 2 keyFrameTime = trimEnd / 2

View File

@@ -67,7 +67,7 @@ struct HomeView: View {
showRecentWorks = true showRecentWorks = true
} }
} }
.sensoryFeedback(.impact(weight: .light), trigger: deleteWorkCount) .sensoryFeedback(.warning, trigger: deleteWorkCount)
} }
// MARK: - Hero // MARK: - Hero
@@ -248,7 +248,7 @@ struct HomeView: View {
Button(String(localized: "home.clearAll"), role: .destructive) { Button(String(localized: "home.clearAll"), role: .destructive) {
recentWorks.clearAll() recentWorks.clearAll()
} }
Button("Cancel", role: .cancel) {} Button(String(localized: "common.cancel"), role: .cancel) {}
} message: { } message: {
Text(String(localized: "home.clearAllConfirmMessage")) Text(String(localized: "home.clearAllConfirmMessage"))
} }

View File

@@ -17,6 +17,9 @@ struct ProcessingView: View {
@State private var hasStarted = false @State private var hasStarted = false
@State private var pulseAnimation = false @State private var pulseAnimation = false
/// LivePhotoBuildProgress.Stage case
private let totalStages = 7
var body: some View { var body: some View {
ZStack { ZStack {
// //
@@ -89,7 +92,6 @@ struct ProcessingView: View {
Circle() Circle()
.fill(Color.accentPurple.opacity(0.1)) .fill(Color.accentPurple.opacity(0.1))
.frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160) .frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160)
.animation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true), value: pulseAnimation)
// //
SoftProgressRing( SoftProgressRing(
@@ -133,7 +135,7 @@ struct ProcessingView: View {
// //
HStack(spacing: DesignTokens.Spacing.sm) { HStack(spacing: DesignTokens.Spacing.sm) {
ForEach(0..<7) { index in ForEach(0..<totalStages, id: \.self) { index in
Circle() Circle()
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed) .fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
@@ -225,7 +227,7 @@ struct ProcessingView: View {
/// = ( + fraction) / /// = ( + fraction) /
private var overallProgress: Double { private var overallProgress: Double {
let totalStages = 7.0 let totalStages = Double(self.totalStages)
let stageFraction = appState.processingProgress?.fraction ?? 0 let stageFraction = appState.processingProgress?.fraction ?? 0
return (Double(currentStageIndex) + stageFraction) / totalStages return (Double(currentStageIndex) + stageFraction) / totalStages
} }

View File

@@ -55,8 +55,17 @@ struct ResultView: View {
.navigationTitle(String(localized: "result.title")) .navigationTitle(String(localized: "result.title"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.onAppear { .task {
animateIn() // structured concurrency
withAnimation { showIcon = true }
if isSuccess {
try? await Task.sleep(for: .seconds(0.2))
celebrationParticles = true
}
try? await Task.sleep(for: .seconds(0.3))
showContent = true
try? await Task.sleep(for: .seconds(0.2))
showButtons = true
} }
.task { .task {
guard isSuccess else { return } guard isSuccess else { return }
@@ -203,29 +212,6 @@ struct ResultView: View {
!workflowResult.savedAssetId.isEmpty !workflowResult.savedAssetId.isEmpty
} }
// MARK: -
private func animateIn() {
//
withAnimation {
showIcon = true
}
if isSuccess {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
celebrationParticles = true
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showContent = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showButtons = true
}
}
// MARK: - Live Photo // MARK: - Live Photo
private func loadLivePhoto() async { private func loadLivePhoto() async {