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:
@@ -82,13 +82,23 @@ public struct CropRect: Codable, Sendable, Hashable {
|
||||
/// 全画幅(不裁剪)
|
||||
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 {
|
||||
CGRect(
|
||||
x: x * videoSize.width,
|
||||
y: y * videoSize.height,
|
||||
width: width * videoSize.width,
|
||||
height: height * videoSize.height
|
||||
let safe = clamped()
|
||||
return CGRect(
|
||||
x: safe.x * videoSize.width,
|
||||
y: safe.y * videoSize.height,
|
||||
width: safe.width * videoSize.width,
|
||||
height: safe.height * videoSize.height
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -521,10 +531,10 @@ public actor LivePhotoBuilder {
|
||||
public func buildResources(
|
||||
workId: UUID = UUID(),
|
||||
sourceVideoURL: URL,
|
||||
coverImageURL: URL? = nil,
|
||||
exportParams: ExportParams = ExportParams(),
|
||||
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
||||
) async throws -> LivePhotoBuildOutput {
|
||||
let coverImageURL = exportParams.coverImageURL
|
||||
let assetIdentifier = UUID().uuidString
|
||||
let paths = try cacheManager.makeWorkPaths(workId: workId)
|
||||
|
||||
@@ -1070,7 +1080,7 @@ public actor LivePhotoBuilder {
|
||||
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
|
||||
currentFrameCount += 1
|
||||
let pct = Double(currentFrameCount) / Double(frameCount)
|
||||
progress(pct)
|
||||
progress(min(pct, 1.0))
|
||||
videoWriterInput.append(sampleBuffer)
|
||||
} else {
|
||||
videoWriterInput.markAsFinished()
|
||||
@@ -1192,14 +1202,12 @@ public actor LivePhotoWorkflow {
|
||||
public func buildSaveValidate(
|
||||
workId: UUID = UUID(),
|
||||
sourceVideoURL: URL,
|
||||
coverImageURL: URL? = nil,
|
||||
exportParams: ExportParams = ExportParams(),
|
||||
progress: (@Sendable (LivePhotoBuildProgress) -> Void)? = nil
|
||||
) async throws -> LivePhotoWorkflowResult {
|
||||
let output = try await builder.buildResources(
|
||||
workId: workId,
|
||||
sourceVideoURL: sourceVideoURL,
|
||||
coverImageURL: coverImageURL,
|
||||
exportParams: exportParams,
|
||||
progress: progress
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// App 全局状态管理 + 页面导航状态机
|
||||
//
|
||||
|
||||
import os
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import LivePhotoCore
|
||||
@@ -34,11 +35,13 @@ final class AppState {
|
||||
private var workflow: LivePhotoWorkflow?
|
||||
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
|
||||
private var currentWorkId: UUID?
|
||||
private let logger = Logger(subsystem: "ToLivePhoto", category: "AppState")
|
||||
|
||||
init() {
|
||||
do {
|
||||
workflow = try LivePhotoWorkflow()
|
||||
} catch {
|
||||
logger.error("Failed to init LivePhotoWorkflow: \(error.localizedDescription, privacy: .public)")
|
||||
#if DEBUG
|
||||
print("Failed to init LivePhotoWorkflow: \(error)")
|
||||
#endif
|
||||
@@ -117,7 +120,6 @@ final class AppState {
|
||||
let result = try await workflow.buildSaveValidate(
|
||||
workId: workId,
|
||||
sourceVideoURL: videoURL,
|
||||
coverImageURL: exportParams.coverImageURL,
|
||||
exportParams: exportParams
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -18,6 +18,37 @@ struct EditingPreset: Codable, Identifiable, Hashable {
|
||||
let trimDuration: Double
|
||||
let aiEnhance: 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 个预设)
|
||||
@@ -31,6 +62,8 @@ final class PresetManager: ObservableObject {
|
||||
private let storageKey = "editing_presets_v1"
|
||||
private let iCloudKey = "editing_presets_v1"
|
||||
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||
/// 防止 iCloud 合并回写触发循环
|
||||
private var isHandlingICloudChange = false
|
||||
|
||||
private init() {
|
||||
loadFromStorage()
|
||||
@@ -92,10 +125,19 @@ final class PresetManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
isHandlingICloudChange = true
|
||||
defer { isHandlingICloudChange = false }
|
||||
|
||||
switch reason {
|
||||
case NSUbiquitousKeyValueStoreServerChange,
|
||||
NSUbiquitousKeyValueStoreInitialSyncChange:
|
||||
mergeFromICloud()
|
||||
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
||||
#if DEBUG
|
||||
print("[PresetManager] iCloud quota violated — data may not sync")
|
||||
#endif
|
||||
case NSUbiquitousKeyValueStoreAccountChange:
|
||||
mergeFromICloud()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -114,7 +156,7 @@ final class PresetManager: ObservableObject {
|
||||
|
||||
merged.sort { $0.createdAt > $1.createdAt }
|
||||
presets = Array(merged.prefix(maxCount))
|
||||
saveToLocalOnly()
|
||||
persistToStorage()
|
||||
}
|
||||
|
||||
// MARK: - 持久化
|
||||
@@ -138,6 +180,18 @@ final class PresetManager: ObservableObject {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(presets)
|
||||
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)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
@@ -145,15 +199,4 @@ final class PresetManager: ObservableObject {
|
||||
#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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import LivePhotoCore
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
@@ -15,18 +16,20 @@ struct RecentWork: Codable, Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let createdAt: Date
|
||||
let assetLocalIdentifier: String // PHAsset 的 localIdentifier
|
||||
let aspectRatioRaw: String // AspectRatioTemplate.rawValue
|
||||
let aspectRatioRaw: String // AspectRatioTemplate.rawValue(保留以兼容旧数据)
|
||||
let compatibilityMode: Bool
|
||||
|
||||
/// 类型安全的枚举访问,rawValue 无法匹配时返回 nil
|
||||
var aspectRatio: AspectRatioTemplate? {
|
||||
AspectRatioTemplate(rawValue: aspectRatioRaw)
|
||||
}
|
||||
|
||||
/// 优先使用枚举的 displayName,fallback 到 raw string
|
||||
var aspectRatioDisplayName: String {
|
||||
switch aspectRatioRaw {
|
||||
case "original": return String(localized: "aspectRatio.original")
|
||||
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
|
||||
if let template = aspectRatio {
|
||||
return template.displayName
|
||||
}
|
||||
return aspectRatioRaw
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +44,8 @@ final class RecentWorksManager: ObservableObject {
|
||||
private let userDefaultsKey = "recent_works_v1"
|
||||
private let iCloudKey = "recent_works_v1"
|
||||
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||
/// 防止 iCloud 合并回写触发循环
|
||||
private var isHandlingICloudChange = false
|
||||
|
||||
private init() {
|
||||
loadFromStorage()
|
||||
@@ -130,10 +135,19 @@ final class RecentWorksManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
isHandlingICloudChange = true
|
||||
defer { isHandlingICloudChange = false }
|
||||
|
||||
switch reason {
|
||||
case NSUbiquitousKeyValueStoreServerChange,
|
||||
NSUbiquitousKeyValueStoreInitialSyncChange:
|
||||
mergeFromICloud()
|
||||
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
||||
#if DEBUG
|
||||
print("[RecentWorksManager] iCloud quota violated — data may not sync")
|
||||
#endif
|
||||
case NSUbiquitousKeyValueStoreAccountChange:
|
||||
mergeFromICloud()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -152,7 +166,7 @@ final class RecentWorksManager: ObservableObject {
|
||||
|
||||
merged.sort { $0.createdAt > $1.createdAt }
|
||||
recentWorks = Array(merged.prefix(maxCount))
|
||||
saveToLocalOnly()
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
// MARK: - 持久化
|
||||
@@ -176,18 +190,19 @@ final class RecentWorksManager: ObservableObject {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(recentWorks)
|
||||
UserDefaults.standard.set(data, forKey: userDefaultsKey)
|
||||
iCloudStore.set(data, forKey: iCloudKey)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RecentWorksManager] Failed to encode: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func saveToLocalOnly() {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(recentWorks)
|
||||
UserDefaults.standard.set(data, forKey: userDefaultsKey)
|
||||
// 标志位为 true 时只写本地,防止 iCloud 写入循环
|
||||
guard !isHandlingICloudChange else { return }
|
||||
|
||||
// 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 {
|
||||
#if DEBUG
|
||||
print("[RecentWorksManager] Failed to encode: \(error)")
|
||||
|
||||
@@ -59,10 +59,14 @@ struct EditorView: View {
|
||||
@State private var presetName = ""
|
||||
@State private var showPresetPicker = false
|
||||
|
||||
// 视图生命周期
|
||||
@State private var isViewActive = true
|
||||
|
||||
// 触觉反馈触发
|
||||
@State private var generateTapCount: Int = 0
|
||||
@State private var presetSavedCount: Int = 0
|
||||
@State private var coverImportCount: Int = 0
|
||||
@State private var cropScaleHapticCount: Int = 0
|
||||
|
||||
/// 是否使用 iPad 分栏布局(regular 宽度 + 横屏)
|
||||
private var useIPadLayout: Bool {
|
||||
@@ -80,9 +84,11 @@ struct EditorView: View {
|
||||
.navigationTitle(String(localized: "editor.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
isViewActive = true
|
||||
loadVideo()
|
||||
}
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
player?.pause()
|
||||
coverExtractionTask?.cancel()
|
||||
coverExtractionTask = nil
|
||||
@@ -90,7 +96,7 @@ struct EditorView: View {
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: lastCropScale)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: cropScaleHapticCount)
|
||||
.sensoryFeedback(.success, trigger: presetSavedCount)
|
||||
.sensoryFeedback(.selection, trigger: coverImportCount)
|
||||
}
|
||||
@@ -147,6 +153,7 @@ struct EditorView: View {
|
||||
presetSection
|
||||
generateButton
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.lg)
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
}
|
||||
.frame(minWidth: 320, maxWidth: 420)
|
||||
@@ -163,6 +170,7 @@ struct EditorView: View {
|
||||
}
|
||||
.onEnded { value in
|
||||
lastCropScale = max(1.0, min(3.0, lastCropScale * value))
|
||||
cropScaleHapticCount += 1
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
@@ -371,7 +379,7 @@ struct EditorView: View {
|
||||
|
||||
if trimEnd - trimStart > 3 {
|
||||
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundStyle(Color.accentOrange)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.durationShareWarning"))
|
||||
@@ -434,7 +442,7 @@ struct EditorView: View {
|
||||
HStack {
|
||||
Image(systemName: "wand.and.stars.inverse")
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||
Text(String(localized: "editor.aiEnhance"))
|
||||
.font(.headline)
|
||||
Text(String(localized: "editor.aiEnhanceDescription"))
|
||||
@@ -473,7 +481,7 @@ struct EditorView: View {
|
||||
}
|
||||
|
||||
if aiEnhanceEnabled && !aiModelDownloading {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||
if aiModelNeedsDownload {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
@@ -538,7 +546,7 @@ struct EditorView: View {
|
||||
HStack {
|
||||
Image(systemName: "gearshape.2")
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||
Text(String(localized: "editor.compatibilityMode"))
|
||||
.font(.headline)
|
||||
Text(String(localized: "editor.compatibilityDescription"))
|
||||
@@ -550,7 +558,7 @@ struct EditorView: View {
|
||||
.tint(.accentColor)
|
||||
|
||||
if compatibilityMode {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
@@ -630,7 +638,7 @@ struct EditorView: View {
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.padding(.top, DesignTokens.Spacing.xs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -703,7 +711,7 @@ struct EditorView: View {
|
||||
applyPreset(preset)
|
||||
showPresetPicker = false
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) {
|
||||
Text(preset.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
@@ -754,6 +762,7 @@ struct EditorView: View {
|
||||
) {
|
||||
startProcessing()
|
||||
}
|
||||
.accessibilityLabel(String(localized: "editor.generateButton"))
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
.padding(.bottom, DesignTokens.Spacing.sm)
|
||||
}
|
||||
@@ -814,6 +823,7 @@ struct EditorView: View {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
guard isViewActive else { return }
|
||||
videoDuration = durationSeconds
|
||||
trimEnd = max(0.1, min(1.0, durationSeconds))
|
||||
keyFrameTime = trimEnd / 2
|
||||
|
||||
@@ -67,7 +67,7 @@ struct HomeView: View {
|
||||
showRecentWorks = true
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: deleteWorkCount)
|
||||
.sensoryFeedback(.warning, trigger: deleteWorkCount)
|
||||
}
|
||||
|
||||
// MARK: - Hero 区域
|
||||
@@ -248,7 +248,7 @@ struct HomeView: View {
|
||||
Button(String(localized: "home.clearAll"), role: .destructive) {
|
||||
recentWorks.clearAll()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "home.clearAllConfirmMessage"))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ struct ProcessingView: View {
|
||||
@State private var hasStarted = false
|
||||
@State private var pulseAnimation = false
|
||||
|
||||
/// 处理阶段总数(对应 LivePhotoBuildProgress.Stage 的 case 数量)
|
||||
private let totalStages = 7
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 背景
|
||||
@@ -89,7 +92,6 @@ struct ProcessingView: View {
|
||||
Circle()
|
||||
.fill(Color.accentPurple.opacity(0.1))
|
||||
.frame(width: pulseAnimation ? 175 : 160, height: pulseAnimation ? 175 : 160)
|
||||
.animation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true), value: pulseAnimation)
|
||||
|
||||
// 进度环
|
||||
SoftProgressRing(
|
||||
@@ -133,7 +135,7 @@ struct ProcessingView: View {
|
||||
|
||||
// 阶段指示器
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
ForEach(0..<7) { index in
|
||||
ForEach(0..<totalStages, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
|
||||
.frame(width: 8, height: 8)
|
||||
@@ -225,7 +227,7 @@ struct ProcessingView: View {
|
||||
|
||||
/// 全局进度 = (阶段序号 + 阶段内fraction) / 总阶段数
|
||||
private var overallProgress: Double {
|
||||
let totalStages = 7.0
|
||||
let totalStages = Double(self.totalStages)
|
||||
let stageFraction = appState.processingProgress?.fraction ?? 0
|
||||
return (Double(currentStageIndex) + stageFraction) / totalStages
|
||||
}
|
||||
|
||||
@@ -55,8 +55,17 @@ struct ResultView: View {
|
||||
.navigationTitle(String(localized: "result.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
animateIn()
|
||||
.task {
|
||||
// 入场动画(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 {
|
||||
guard isSuccess else { return }
|
||||
@@ -203,29 +212,6 @@ struct ResultView: View {
|
||||
!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
|
||||
|
||||
private func loadLivePhoto() async {
|
||||
|
||||
Reference in New Issue
Block a user