fix: 安全审查 P0-P2 问题修复(26项)
P0 关键修复: - 移除 exit(0) 强制退出,改为应用语言设置后下次启动生效 - 修复 LivePhotoValidator hasResumed data race,引入线程安全 ResumeOnce - 修复 addAssetID(toVideo:) continuation 泄漏,添加 writer/reader 启动状态检查 - 修复 OnboardingView "跳过" 按钮未国际化 - 修复 LanguageManager "跟随系统" 硬编码中文 - .gitignore 补全 AI 工具目录 P1 架构与 UI 修复: - 修复 RealESRGANProcessor actor 隔离违规 - 修复 ODRManager continuation 生命周期保护 - TiledImageProcessor 改为流式拼接,降低内存峰值 - EditorView 硬编码颜色统一为设计系统 - ProcessingView 取消导航竞态修复 - 反馈诊断包添加知情同意提示 P2 代码质量与合规: - EditorView/WallpaperGuideView 硬编码间距圆角统一为设计令牌 - PrivacyPolicyView 设计系统颜色统一 - HomeView 重复 onChange 合并 - PHAuthorizationStatus 改为英文技术术语 - Analytics 日志 assetId 脱敏 - 隐私政策补充 localIdentifier 存储说明 - 清理孤立的 subscription 翻译 key - 脚本硬编码绝对路径改为相对路径 - DesignSystem SoftSlider 类型不匹配编译错误修复 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -114,3 +114,16 @@ to-live-photo/to-live-photo/build/
|
||||
# PyTorch models (use Core ML instead)
|
||||
*.pth
|
||||
.serena/
|
||||
|
||||
# AI coding tools
|
||||
.agent/
|
||||
.agents/
|
||||
.claude/
|
||||
.codex/
|
||||
.cursor/
|
||||
.gemini/
|
||||
.kiro/
|
||||
.opencode/
|
||||
.qoder/
|
||||
.trae/
|
||||
.windsurf/
|
||||
|
||||
@@ -155,11 +155,13 @@ public actor ODRManager {
|
||||
private func checkODRAvailability() async -> Bool {
|
||||
// Use conditionallyBeginAccessingResources to check without triggering download
|
||||
let request = NSBundleResourceRequest(tags: [Self.modelTag])
|
||||
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
request.conditionallyBeginAccessingResources { available in
|
||||
request.conditionallyBeginAccessingResources { [request] available in
|
||||
// Capture request explicitly to prevent ARC from releasing it
|
||||
// before the callback fires
|
||||
_ = request
|
||||
if available {
|
||||
// Model is already downloaded via ODR
|
||||
self.logger.debug("ODR model is available locally")
|
||||
}
|
||||
continuation.resume(returning: available)
|
||||
|
||||
@@ -112,7 +112,10 @@ actor RealESRGANProcessor {
|
||||
|
||||
logger.info("Running inference on \(width)x\(height) image...")
|
||||
|
||||
// Run inference synchronously (MLModel prediction is thread-safe)
|
||||
// Capture actor-isolated state before entering non-isolated closure
|
||||
let localModel = model
|
||||
|
||||
// Run inference on background queue (MLModel prediction is thread-safe)
|
||||
let output: [UInt8] = try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
@@ -123,22 +126,15 @@ actor RealESRGANProcessor {
|
||||
)
|
||||
|
||||
// Run inference synchronously
|
||||
let prediction = try model.prediction(from: inputProvider)
|
||||
let prediction = try localModel.prediction(from: inputProvider)
|
||||
|
||||
// Extract output from model
|
||||
// The model outputs to "activation_out" as either MultiArray or Image
|
||||
let rgbaData: [UInt8]
|
||||
|
||||
if let outputValue = prediction.featureValue(for: "activation_out") {
|
||||
if let multiArray = outputValue.multiArrayValue {
|
||||
// Output is MLMultiArray with shape [C, H, W]
|
||||
self.logger.info("Output is MultiArray: \(multiArray.shape)")
|
||||
rgbaData = try self.multiArrayToRGBA(multiArray)
|
||||
rgbaData = try Self.multiArrayToRGBA(multiArray)
|
||||
} else if let outputBuffer = outputValue.imageBufferValue {
|
||||
// Output is CVPixelBuffer (image)
|
||||
let outWidth = CVPixelBufferGetWidth(outputBuffer)
|
||||
let outHeight = CVPixelBufferGetHeight(outputBuffer)
|
||||
self.logger.info("Output is Image: \(outWidth)x\(outHeight)")
|
||||
rgbaData = try ImageFormatConverter.pixelBufferToRGBAData(outputBuffer)
|
||||
} else {
|
||||
continuation.resume(throwing: AIEnhanceError.inferenceError(
|
||||
@@ -162,13 +158,14 @@ actor RealESRGANProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Inference completed, output size: \(output.count) bytes")
|
||||
return output
|
||||
}
|
||||
|
||||
/// Convert MLMultiArray [C, H, W] to RGBA byte array
|
||||
/// - Parameter multiArray: Output from model with shape [3, H, W] (RGB channels)
|
||||
/// - Returns: RGBA byte array with shape [H * W * 4]
|
||||
private func multiArrayToRGBA(_ multiArray: MLMultiArray) throws -> [UInt8] {
|
||||
private static func multiArrayToRGBA(_ multiArray: MLMultiArray) throws -> [UInt8] {
|
||||
let shape = multiArray.shape.map { $0.intValue }
|
||||
|
||||
// Expect shape [3, H, W] for RGB
|
||||
@@ -178,12 +175,9 @@ actor RealESRGANProcessor {
|
||||
)
|
||||
}
|
||||
|
||||
let channels = shape[0]
|
||||
let height = shape[1]
|
||||
let width = shape[2]
|
||||
|
||||
logger.info("Converting MultiArray \(channels)x\(height)x\(width) to RGBA")
|
||||
|
||||
// Output array: RGBA format
|
||||
var rgbaData = [UInt8](repeating: 255, count: width * height * 4)
|
||||
|
||||
|
||||
@@ -63,17 +63,38 @@ struct TiledImageProcessor {
|
||||
logger.info("Extracted \(tiles.count) tiles")
|
||||
progress?(0.1)
|
||||
|
||||
// Step 2: Process each tile
|
||||
var processedTiles: [(tile: ImageTile, output: [UInt8])] = []
|
||||
// Step 2: Pre-allocate output buffers for streaming stitching
|
||||
let outputWidth = originalWidth * config.modelScale
|
||||
let outputHeight = originalHeight * config.modelScale
|
||||
var outputBuffer = [Float](repeating: 0, count: outputWidth * outputHeight * 3)
|
||||
var weightBuffer = [Float](repeating: 0, count: outputWidth * outputHeight)
|
||||
|
||||
// Step 3: Process each tile and blend immediately (streaming)
|
||||
let tileProgressBase = 0.1
|
||||
let tileProgressRange = 0.7
|
||||
let tileProgressRange = 0.75
|
||||
|
||||
for (index, tile) in tiles.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
|
||||
let pixelBuffer = try ImageFormatConverter.cgImageToPixelBuffer(tile.image)
|
||||
let outputData = try await processor.processImage(pixelBuffer)
|
||||
processedTiles.append((tile, outputData))
|
||||
|
||||
// Blend tile into output immediately — no accumulation
|
||||
let weights = createBlendingWeights(
|
||||
tileWidth: min(config.outputTileSize, outputWidth - tile.outputOriginX),
|
||||
tileHeight: min(config.outputTileSize, outputHeight - tile.outputOriginY)
|
||||
)
|
||||
blendTileIntoOutput(
|
||||
data: outputData,
|
||||
weights: weights,
|
||||
atX: tile.outputOriginX,
|
||||
atY: tile.outputOriginY,
|
||||
outputWidth: outputWidth,
|
||||
outputHeight: outputHeight,
|
||||
outputBuffer: &outputBuffer,
|
||||
weightBuffer: &weightBuffer
|
||||
)
|
||||
// outputData and weights are released here
|
||||
|
||||
let tileProgress = tileProgressBase + tileProgressRange * Double(index + 1) / Double(tiles.count)
|
||||
progress?(tileProgress)
|
||||
@@ -82,19 +103,14 @@ struct TiledImageProcessor {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
progress?(0.85)
|
||||
progress?(0.9)
|
||||
|
||||
// Step 3: Stitch tiles with blending
|
||||
let outputWidth = originalWidth * config.modelScale
|
||||
let outputHeight = originalHeight * config.modelScale
|
||||
let stitchedImage = try stitchTiles(
|
||||
processedTiles,
|
||||
outputWidth: outputWidth,
|
||||
outputHeight: outputHeight
|
||||
)
|
||||
// Step 4: Normalize and create final image
|
||||
normalizeByWeights(&outputBuffer, weights: weightBuffer, width: outputWidth, height: outputHeight)
|
||||
let stitchedImage = try createCGImage(from: outputBuffer, width: outputWidth, height: outputHeight)
|
||||
progress?(0.95)
|
||||
|
||||
// Step 4: Cap at max dimension if needed
|
||||
// Step 5: Cap at max dimension if needed
|
||||
let finalImage = try capToMaxDimension(stitchedImage, maxDimension: 4320)
|
||||
progress?(1.0)
|
||||
|
||||
@@ -196,45 +212,6 @@ struct TiledImageProcessor {
|
||||
|
||||
// MARK: - Tile Stitching
|
||||
|
||||
/// Stitch processed tiles with weighted blending
|
||||
private func stitchTiles(
|
||||
_ tiles: [(tile: ImageTile, output: [UInt8])],
|
||||
outputWidth: Int,
|
||||
outputHeight: Int
|
||||
) throws -> CGImage {
|
||||
// Create output buffers
|
||||
var outputBuffer = [Float](repeating: 0, count: outputWidth * outputHeight * 3)
|
||||
var weightBuffer = [Float](repeating: 0, count: outputWidth * outputHeight)
|
||||
|
||||
let outputTileSize = config.outputTileSize // 2048
|
||||
|
||||
for (tile, data) in tiles {
|
||||
// Create blending weights for this tile
|
||||
let weights = createBlendingWeights(
|
||||
tileWidth: min(outputTileSize, outputWidth - tile.outputOriginX),
|
||||
tileHeight: min(outputTileSize, outputHeight - tile.outputOriginY)
|
||||
)
|
||||
|
||||
// Blend tile into output
|
||||
blendTileIntoOutput(
|
||||
data: data,
|
||||
weights: weights,
|
||||
atX: tile.outputOriginX,
|
||||
atY: tile.outputOriginY,
|
||||
outputWidth: outputWidth,
|
||||
outputHeight: outputHeight,
|
||||
outputBuffer: &outputBuffer,
|
||||
weightBuffer: &weightBuffer
|
||||
)
|
||||
}
|
||||
|
||||
// Normalize by accumulated weights
|
||||
normalizeByWeights(&outputBuffer, weights: weightBuffer, width: outputWidth, height: outputHeight)
|
||||
|
||||
// Convert to CGImage
|
||||
return try createCGImage(from: outputBuffer, width: outputWidth, height: outputHeight)
|
||||
}
|
||||
|
||||
/// Create blending weights with linear falloff at edges
|
||||
private func createBlendingWeights(tileWidth: Int, tileHeight: Int) -> [Float] {
|
||||
let overlap = config.outputOverlap // 256
|
||||
|
||||
@@ -347,6 +347,21 @@ public actor AlbumWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 线程安全的一次性消费守卫,防止 continuation 被 resume 多次
|
||||
private final class ResumeOnce: @unchecked Sendable {
|
||||
private var _consumed = false
|
||||
private let lock = NSLock()
|
||||
|
||||
/// 尝试消费。仅第一次调用返回 true,后续调用均返回 false。
|
||||
func tryConsume() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if _consumed { return false }
|
||||
_consumed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public actor LivePhotoValidator {
|
||||
public init() {}
|
||||
|
||||
@@ -378,16 +393,13 @@ public actor LivePhotoValidator {
|
||||
|
||||
public func requestLivePhoto(photoURL: URL, pairedVideoURL: URL) async -> PHLivePhoto? {
|
||||
await withCheckedContinuation { continuation in
|
||||
var hasResumed = false
|
||||
let resumeOnce = ResumeOnce()
|
||||
let requestID = PHLivePhoto.request(
|
||||
withResourceFileURLs: [pairedVideoURL, photoURL],
|
||||
placeholderImage: nil,
|
||||
targetSize: .zero,
|
||||
contentMode: .aspectFit
|
||||
) { livePhoto, info in
|
||||
// 确保只 resume 一次
|
||||
guard !hasResumed else { return }
|
||||
|
||||
// 如果是降级版本,等待完整版本
|
||||
if let isDegraded = info[PHLivePhotoInfoIsDegradedKey] as? Bool, isDegraded {
|
||||
return
|
||||
@@ -398,8 +410,9 @@ public actor LivePhotoValidator {
|
||||
#if DEBUG
|
||||
print("[LivePhotoValidator] requestLivePhoto error: \(error.localizedDescription)")
|
||||
#endif
|
||||
hasResumed = true
|
||||
continuation.resume(returning: nil)
|
||||
if resumeOnce.tryConsume() {
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -407,23 +420,24 @@ public actor LivePhotoValidator {
|
||||
#if DEBUG
|
||||
print("[LivePhotoValidator] requestLivePhoto cancelled")
|
||||
#endif
|
||||
hasResumed = true
|
||||
continuation.resume(returning: nil)
|
||||
if resumeOnce.tryConsume() {
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hasResumed = true
|
||||
continuation.resume(returning: livePhoto)
|
||||
if resumeOnce.tryConsume() {
|
||||
continuation.resume(returning: livePhoto)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加超时保护,防止无限等待
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||
guard !hasResumed else { return }
|
||||
guard resumeOnce.tryConsume() else { return }
|
||||
#if DEBUG
|
||||
print("[LivePhotoValidator] requestLivePhoto timeout, requestID: \(requestID)")
|
||||
#endif
|
||||
PHLivePhoto.cancelRequest(withRequestID: requestID)
|
||||
hasResumed = true
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
@@ -966,6 +980,23 @@ public actor LivePhotoBuilder {
|
||||
assetWriter.startWriting()
|
||||
videoReader.startReading()
|
||||
metadataReader.startReading()
|
||||
|
||||
// 检查 writer/reader 是否成功启动,防止 continuation 永不 resume
|
||||
guard assetWriter.status == .writing else {
|
||||
continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: assetWriter.error?.localizedDescription ?? "Writer 启动失败", suggestedActions: ["重试"]))
|
||||
return
|
||||
}
|
||||
guard videoReader.status == .reading else {
|
||||
assetWriter.cancelWriting()
|
||||
continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: videoReader.error?.localizedDescription ?? "VideoReader 启动失败", suggestedActions: ["重试"]))
|
||||
return
|
||||
}
|
||||
guard metadataReader.status == .reading else {
|
||||
assetWriter.cancelWriting()
|
||||
continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: metadataReader.error?.localizedDescription ?? "MetadataReader 启动失败", suggestedActions: ["重试"]))
|
||||
return
|
||||
}
|
||||
|
||||
assetWriter.startSession(atSourceTime: .zero)
|
||||
|
||||
var currentFrameCount = 0
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/bin/bash
|
||||
# 多语言快速截图脚本
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# 语言参数
|
||||
LANGUAGE=${1:-zh-Hans}
|
||||
|
||||
SCREENSHOT_DIR="/Users/yuanjiantsui/projects/to-live-photo/app-store-screenshots/$LANGUAGE/6.7inch"
|
||||
SCREENSHOT_DIR="$SCRIPT_DIR/app-store-screenshots/$LANGUAGE/6.7inch"
|
||||
mkdir -p "$SCREENSHOT_DIR"
|
||||
|
||||
COUNTER=1
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/bin/bash
|
||||
# 将 6.9" 截图缩放为 6.5" 截图
|
||||
|
||||
SOURCE_DIR="/Users/yuanjiantsui/projects/to-live-photo/app-store-screenshots"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SOURCE_DIR="$SCRIPT_DIR/app-store-screenshots"
|
||||
LANGUAGES=("ja" "zh-Hans" "zh-Hant" "en" "es" "ar" "fr" "ko")
|
||||
|
||||
echo "📐 开始缩放截图: 6.9\" (1320x2868) → 6.5\" (1284x2778)"
|
||||
|
||||
@@ -76,6 +76,7 @@ final class AppState {
|
||||
self.currentWorkId = nil
|
||||
self.currentProcessingTask = nil
|
||||
self.processingProgress = nil
|
||||
self.pop()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -84,6 +85,7 @@ final class AppState {
|
||||
currentWorkId = nil
|
||||
currentProcessingTask = nil
|
||||
processingProgress = nil
|
||||
pop()
|
||||
}
|
||||
|
||||
Analytics.shared.log(.buildLivePhotoCancel)
|
||||
@@ -142,7 +144,7 @@ final class AppState {
|
||||
state.currentExportParams = nil
|
||||
}
|
||||
Analytics.shared.log(.buildLivePhotoSuccess)
|
||||
Analytics.shared.log(.saveAlbumSuccess, parameters: ["assetId": result.savedAssetId])
|
||||
Analytics.shared.log(.saveAlbumSuccess, parameters: ["assetId": String(result.savedAssetId.prefix(8)) + "..."])
|
||||
return result
|
||||
} catch is CancellationError {
|
||||
// 任务被取消,不需要额外处理
|
||||
|
||||
@@ -530,19 +530,25 @@ struct SoftSlider: View {
|
||||
let gradient: LinearGradient
|
||||
let accessibilityLabel: String
|
||||
let step: Double
|
||||
let onEditingChanged: ((Bool) -> Void)?
|
||||
let isDisabled: Bool
|
||||
|
||||
init(
|
||||
value: Binding<Double>,
|
||||
in range: ClosedRange<Double>,
|
||||
step: Double = 0.1,
|
||||
gradient: LinearGradient = Color.gradientPrimary,
|
||||
accessibilityLabel: String = ""
|
||||
accessibilityLabel: String = "",
|
||||
isDisabled: Bool = false,
|
||||
onEditingChanged: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
self._value = value
|
||||
self.range = range
|
||||
self.step = step
|
||||
self.gradient = gradient
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.isDisabled = isDisabled
|
||||
self.onEditingChanged = onEditingChanged
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -560,7 +566,7 @@ struct SoftSlider: View {
|
||||
|
||||
// 进度填充
|
||||
Capsule()
|
||||
.fill(gradient)
|
||||
.fill(isDisabled ? LinearGradient(colors: [Color.softPressed], startPoint: .leading, endPoint: .trailing) : gradient)
|
||||
.frame(width: max(0, thumbX), height: 8)
|
||||
|
||||
// 滑块
|
||||
@@ -572,18 +578,25 @@ struct SoftSlider: View {
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { gesture in
|
||||
guard !isDisabled else { return }
|
||||
let newProgress = gesture.location.x / width
|
||||
let clampedProgress = max(0, min(1, newProgress))
|
||||
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
|
||||
onEditingChanged?(true)
|
||||
}
|
||||
.onEnded { _ in
|
||||
onEditingChanged?(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
.opacity(isDisabled ? 0.5 : 1.0)
|
||||
}
|
||||
.frame(height: 28)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel)
|
||||
.accessibilityValue(Text(String(format: "%.1f", value)))
|
||||
.accessibilityAdjustableAction { direction in
|
||||
guard !isDisabled else { return }
|
||||
switch direction {
|
||||
case .increment:
|
||||
value = min(range.upperBound, value + step)
|
||||
|
||||
@@ -20,7 +20,7 @@ final class LanguageManager {
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .system: return "跟随系统"
|
||||
case .system: return String(localized: "settings.language.system")
|
||||
case .zhHans: return "简体中文"
|
||||
case .zhHant: return "繁體中文"
|
||||
case .en: return "English"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -241,9 +241,9 @@ struct EditorView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
.padding(16)
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
|
||||
// MARK: - 封面帧预览
|
||||
@@ -289,9 +289,9 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
|
||||
// MARK: - 时长控制
|
||||
@@ -310,18 +310,25 @@ struct EditorView: View {
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
|
||||
Slider(value: $trimEnd, in: 1.0...max(1.0, min(1.5, videoDuration))) { _ in
|
||||
updateKeyFrameTime()
|
||||
}
|
||||
.disabled(videoDuration < 1.0)
|
||||
SoftSlider(
|
||||
value: $trimEnd,
|
||||
in: 1.0...max(1.0, min(1.5, videoDuration)),
|
||||
step: 0.1,
|
||||
gradient: Color.gradientPrimary,
|
||||
accessibilityLabel: String(localized: "editor.videoDuration"),
|
||||
isDisabled: videoDuration < 1.0,
|
||||
onEditingChanged: { _ in
|
||||
updateKeyFrameTime()
|
||||
}
|
||||
)
|
||||
|
||||
Text(String(localized: "editor.durationHint"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
.padding(16)
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
|
||||
// MARK: - 封面帧时间选择
|
||||
@@ -340,19 +347,26 @@ struct EditorView: View {
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
|
||||
Slider(value: $keyFrameTime, in: trimStart...max(trimStart + 0.1, trimEnd)) { editing in
|
||||
if !editing {
|
||||
extractCoverFrame()
|
||||
SoftSlider(
|
||||
value: $keyFrameTime,
|
||||
in: trimStart...max(trimStart + 0.1, trimEnd),
|
||||
step: 0.05,
|
||||
gradient: Color.gradientCyan,
|
||||
accessibilityLabel: String(localized: "editor.keyFrameTime"),
|
||||
onEditingChanged: { editing in
|
||||
if !editing {
|
||||
extractCoverFrame()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Text(String(localized: "editor.keyFrameHint"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
.padding(16)
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
|
||||
// MARK: - AI 超分辨率开关
|
||||
@@ -362,7 +376,7 @@ struct EditorView: View {
|
||||
Toggle(isOn: $aiEnhanceEnabled) {
|
||||
HStack {
|
||||
Image(systemName: "wand.and.stars.inverse")
|
||||
.foregroundStyle(.purple)
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(localized: "editor.aiEnhance"))
|
||||
.font(.headline)
|
||||
@@ -372,7 +386,7 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.purple)
|
||||
.tint(Color.accentPurple)
|
||||
.disabled(!AIEnhancer.isAvailable() || aiModelDownloading)
|
||||
.onChange(of: aiEnhanceEnabled) { _, newValue in
|
||||
if newValue {
|
||||
@@ -392,8 +406,8 @@ struct EditorView: View {
|
||||
}
|
||||
|
||||
ProgressView(value: aiModelDownloadProgress)
|
||||
.tint(.purple)
|
||||
|
||||
.tint(Color.accentPurple)
|
||||
|
||||
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.textSecondary)
|
||||
@@ -414,21 +428,21 @@ struct EditorView: View {
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.purple)
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.aiResolutionBoost"))
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(.purple)
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.aiProcessingTime"))
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "cpu")
|
||||
.foregroundStyle(.purple)
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
.font(.caption)
|
||||
Text(String(localized: "editor.aiLocalProcessing"))
|
||||
.font(.caption)
|
||||
@@ -450,9 +464,9 @@ struct EditorView: View {
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.purple.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.accentPurple.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
.task {
|
||||
// 检查模型是否需要下载
|
||||
aiModelNeedsDownload = await AIEnhancer.needsDownload()
|
||||
@@ -513,9 +527,9 @@ struct EditorView: View {
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
|
||||
// MARK: - 诊断建议
|
||||
@@ -561,9 +575,9 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.yellow.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.background(Color.accentOrange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
|
||||
// MARK: - 生成按钮
|
||||
|
||||
@@ -72,11 +72,11 @@ struct HomeView: View {
|
||||
|
||||
VStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Text(String(localized: "home.title"))
|
||||
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
|
||||
.font(.title2.bold())
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Text(String(localized: "home.subtitle"))
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ struct HomeView: View {
|
||||
Image(systemName: "video.badge.plus")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text(String(localized: "home.selectVideo"))
|
||||
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -102,9 +102,6 @@ struct HomeView: View {
|
||||
}
|
||||
.buttonStyle(HomeButtonStyle())
|
||||
.disabled(isLoading)
|
||||
.onChange(of: selectedItem) { _, _ in
|
||||
Analytics.shared.log(.homeImportVideoClick)
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
if isLoading {
|
||||
@@ -112,7 +109,7 @@ struct HomeView: View {
|
||||
ProgressView()
|
||||
.tint(.accentPurple)
|
||||
Text(String(localized: "home.loading"))
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
@@ -124,7 +121,7 @@ struct HomeView: View {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.accentOrange)
|
||||
Text(errorMessage)
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.accentOrange)
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
@@ -150,7 +147,7 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
Text(String(localized: "home.quickStart"))
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
||||
.font(.headline)
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Spacer()
|
||||
@@ -166,7 +163,7 @@ struct HomeView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(String(localized: "home.emptyHint"))
|
||||
.font(.system(size: DesignTokens.FontSize.xs))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.textMuted)
|
||||
Spacer()
|
||||
}
|
||||
@@ -191,13 +188,13 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
Text(String(localized: "home.recentWorks"))
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
||||
.font(.headline)
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(localized: "home.worksCount \(recentWorks.recentWorks.count)"))
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.textMuted)
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xs)
|
||||
@@ -219,6 +216,7 @@ struct HomeView: View {
|
||||
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
|
||||
Analytics.shared.log(.homeImportVideoClick)
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
@@ -254,12 +252,12 @@ struct QuickStartStep: View {
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Text("\(number)")
|
||||
.font(.system(size: DesignTokens.FontSize.xs, weight: .bold))
|
||||
.font(.caption2.bold())
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.textSecondary)
|
||||
|
||||
Spacer()
|
||||
@@ -336,11 +334,11 @@ struct RecentWorkCard: View {
|
||||
// 信息
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(work.aspectRatioDisplayName)
|
||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .medium))
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Text(work.createdAt.formatted(.relative(presentation: .named)))
|
||||
.font(.system(size: DesignTokens.FontSize.xs))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,11 @@ struct OnboardingView: View {
|
||||
// 文字内容
|
||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||
Text(page.title)
|
||||
.font(.system(size: DesignTokens.FontSize.xxxl, weight: .bold))
|
||||
.font(.title.bold())
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Text(page.description)
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
@@ -148,7 +148,7 @@ struct OnboardingView: View {
|
||||
} label: {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Text(currentPage < pages.count - 1 ? String(localized: "onboarding.nextStep") : String(localized: "onboarding.getStarted"))
|
||||
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Image(systemName: currentPage < pages.count - 1 ? "arrow.right" : "checkmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
@@ -168,8 +168,8 @@ struct OnboardingView: View {
|
||||
Button {
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("跳过")
|
||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .medium))
|
||||
Text(String(localized: "onboarding.skip"))
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(.textMuted)
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
|
||||
@@ -13,7 +13,7 @@ struct PrivacyPolicyView: View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("privacy.lastUpdated")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
|
||||
Group {
|
||||
sectionHeader(String(localized: "privacy.overview.title"))
|
||||
@@ -24,15 +24,6 @@ struct PrivacyPolicyView: View {
|
||||
bulletPoint(String(localized: "privacy.localMode.item1.title"), String(localized: "privacy.localMode.item1.desc"))
|
||||
bulletPoint(String(localized: "privacy.localMode.item2.title"), String(localized: "privacy.localMode.item2.desc"))
|
||||
|
||||
sectionHeader(String(localized: "privacy.cloudMode.title"))
|
||||
bulletPoint(String(localized: "privacy.cloudMode.item1.title"), String(localized: "privacy.cloudMode.item1.desc"))
|
||||
bulletPoint(String(localized: "privacy.cloudMode.item2.title"), String(localized: "privacy.cloudMode.item2.desc"))
|
||||
bulletPoint(String(localized: "privacy.cloudMode.item3.title"), String(localized: "privacy.cloudMode.item3.desc"))
|
||||
Text("privacy.cloudMode.warning")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
sectionHeader(String(localized: "privacy.permissions.title"))
|
||||
bulletPoint(String(localized: "privacy.permissions.item1.title"), String(localized: "privacy.permissions.item1.desc"))
|
||||
bulletPoint(String(localized: "privacy.permissions.item2.title"), String(localized: "privacy.permissions.item2.desc"))
|
||||
@@ -45,6 +36,7 @@ struct PrivacyPolicyView: View {
|
||||
sectionHeader(String(localized: "privacy.storage.title"))
|
||||
bulletPoint(String(localized: "privacy.storage.item1.title"), String(localized: "privacy.storage.item1.desc"))
|
||||
bulletPoint(String(localized: "privacy.storage.item2.title"), String(localized: "privacy.storage.item2.desc"))
|
||||
bulletPoint(String(localized: "privacy.storage.item3.title"), String(localized: "privacy.storage.item3.desc"))
|
||||
|
||||
sectionHeader(String(localized: "privacy.thirdParty.title"))
|
||||
bulletPoint(String(localized: "privacy.thirdParty.item1.title"), String(localized: "privacy.thirdParty.item1.desc"))
|
||||
@@ -57,7 +49,7 @@ struct PrivacyPolicyView: View {
|
||||
sectionHeader(String(localized: "privacy.contact.title"))
|
||||
Text("privacy.contact.intro")
|
||||
Text("privacy.contact.email")
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -76,12 +68,12 @@ struct PrivacyPolicyView: View {
|
||||
private func bulletPoint(_ title: String, _ description: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.fontWeight(.medium)
|
||||
Text(description)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +85,7 @@ struct TermsOfServiceView: View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("terms.lastUpdated")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
|
||||
Group {
|
||||
sectionHeader(String(localized: "terms.acceptance.title"))
|
||||
@@ -103,21 +95,11 @@ struct TermsOfServiceView: View {
|
||||
Text("terms.service.desc1")
|
||||
Text("terms.service.desc2")
|
||||
|
||||
sectionHeader(String(localized: "terms.subscription.title"))
|
||||
bulletPoint(String(localized: "terms.subscription.item1.title"), String(localized: "terms.subscription.item1.desc"))
|
||||
bulletPoint(String(localized: "terms.subscription.item2.title"), String(localized: "terms.subscription.item2.desc"))
|
||||
bulletPoint(String(localized: "terms.subscription.item3.title"), String(localized: "terms.subscription.item3.desc"))
|
||||
bulletPoint(String(localized: "terms.subscription.item4.title"), String(localized: "terms.subscription.item4.desc"))
|
||||
|
||||
sectionHeader(String(localized: "terms.limits.title"))
|
||||
bulletPoint(String(localized: "terms.limits.item1.title"), String(localized: "terms.limits.item1.desc"))
|
||||
bulletPoint(String(localized: "terms.limits.item2.title"), String(localized: "terms.limits.item2.desc"))
|
||||
bulletPoint(String(localized: "terms.limits.item3.title"), String(localized: "terms.limits.item3.desc"))
|
||||
|
||||
sectionHeader(String(localized: "terms.cloud.title"))
|
||||
bulletPoint(String(localized: "terms.cloud.item1.title"), String(localized: "terms.cloud.item1.desc"))
|
||||
bulletPoint(String(localized: "terms.cloud.item2.title"), String(localized: "terms.cloud.item2.desc"))
|
||||
|
||||
sectionHeader(String(localized: "terms.disclaimer.title"))
|
||||
bulletPoint(String(localized: "terms.disclaimer.item1.title"), String(localized: "terms.disclaimer.item1.desc"))
|
||||
bulletPoint(String(localized: "terms.disclaimer.item2.title"), String(localized: "terms.disclaimer.item2.desc"))
|
||||
@@ -131,7 +113,7 @@ struct TermsOfServiceView: View {
|
||||
|
||||
sectionHeader(String(localized: "terms.contact.title"))
|
||||
Text("terms.contact.email")
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Color.accentPurple)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -150,12 +132,12 @@ struct TermsOfServiceView: View {
|
||||
private func bulletPoint(_ title: String, _ description: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.fontWeight(.medium)
|
||||
Text(description)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ struct ProcessingView: View {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(String(localized: "processing.cancel")) {
|
||||
appState.cancelProcessing()
|
||||
appState.pop()
|
||||
}
|
||||
.foregroundColor(.accentPurple)
|
||||
}
|
||||
@@ -78,7 +77,7 @@ struct ProcessingView: View {
|
||||
}
|
||||
|
||||
Text(String(localized: "processing.cancelling"))
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
||||
.font(.headline)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
} else {
|
||||
@@ -109,7 +108,7 @@ struct ProcessingView: View {
|
||||
|
||||
if let progress = appState.processingProgress {
|
||||
Text(String(format: "%.0f%%", progress.fraction * 100))
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .bold))
|
||||
.font(.headline.bold())
|
||||
.foregroundColor(.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
@@ -119,13 +118,13 @@ struct ProcessingView: View {
|
||||
// 阶段信息
|
||||
VStack(spacing: DesignTokens.Spacing.md) {
|
||||
Text(stageText)
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
||||
.font(.headline)
|
||||
.foregroundColor(.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut(duration: 0.3), value: stageText)
|
||||
|
||||
Text(stageDescription)
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
@@ -162,11 +161,11 @@ struct ProcessingView: View {
|
||||
if let error = appState.processingError {
|
||||
VStack(spacing: DesignTokens.Spacing.md) {
|
||||
Text(String(localized: "processing.failed"))
|
||||
.font(.system(size: DesignTokens.FontSize.xl, weight: .bold))
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Text(error.message)
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -177,7 +176,7 @@ struct ProcessingView: View {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.foregroundColor(.accentOrange)
|
||||
Text(String(localized: "processing.suggestions"))
|
||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .semibold))
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundColor(.textPrimary)
|
||||
}
|
||||
|
||||
@@ -187,7 +186,7 @@ struct ProcessingView: View {
|
||||
.fill(Color.accentOrange)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(action)
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +100,12 @@ struct ResultView: View {
|
||||
private var resultInfo: some View {
|
||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||
Text(isSuccess ? String(localized: "result.saved") : String(localized: "result.saveFailed"))
|
||||
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
|
||||
.font(.title2.bold())
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
if isSuccess {
|
||||
Text(String(localized: "result.savedDescription"))
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -122,7 +122,7 @@ struct ResultView: View {
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
} else {
|
||||
Text(String(localized: "result.failedDescription"))
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ struct ResultView: View {
|
||||
appState.popToRoot()
|
||||
}
|
||||
} else {
|
||||
SoftPrimaryButton("返回重试", icon: "arrow.counterclockwise", gradient: Color.gradientWarm) {
|
||||
SoftPrimaryButton(String(localized: "result.backToRetry"), icon: "arrow.counterclockwise", gradient: Color.gradientWarm) {
|
||||
appState.pop()
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ struct ValidationBadge: View {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 12))
|
||||
Text(text)
|
||||
.font(.system(size: DesignTokens.FontSize.xs, weight: .medium))
|
||||
.font(.caption2.weight(.medium))
|
||||
}
|
||||
.foregroundColor(color)
|
||||
.padding(.horizontal, DesignTokens.Spacing.md)
|
||||
@@ -212,6 +212,7 @@ struct ValidationBadge: View {
|
||||
// MARK: - 庆祝粒子效果
|
||||
struct CelebrationParticles: View {
|
||||
@State private var particles: [Particle] = []
|
||||
@State private var viewSize: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
@@ -222,22 +223,30 @@ struct CelebrationParticles: View {
|
||||
.position(particle.position)
|
||||
.opacity(particle.opacity)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
generateParticles()
|
||||
.onAppear {
|
||||
viewSize = geometry.size
|
||||
generateParticles(in: geometry.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateParticles() {
|
||||
private func generateParticles(in size: CGSize) {
|
||||
let colors: [Color] = [.accentPurple, .accentPink, .accentGreen, .accentCyan, .accentOrange]
|
||||
|
||||
// 使用实际屏幕尺寸计算粒子位置
|
||||
let horizontalPadding: CGFloat = 50
|
||||
let minX = horizontalPadding
|
||||
let maxX = max(horizontalPadding + 100, size.width - horizontalPadding)
|
||||
let startY = size.height * 0.5 // 从屏幕中部开始
|
||||
let flyDistance = size.height * 0.4 // 飞行距离为屏幕高度的 40%
|
||||
|
||||
for i in 0..<30 {
|
||||
let delay = Double(i) * 0.03
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
let particle = Particle(
|
||||
id: UUID(),
|
||||
position: CGPoint(x: CGFloat.random(in: 50...350), y: 400),
|
||||
position: CGPoint(x: CGFloat.random(in: minX...maxX), y: startY),
|
||||
color: colors.randomElement()!,
|
||||
size: CGFloat.random(in: 6...12),
|
||||
opacity: 1.0
|
||||
@@ -247,7 +256,7 @@ struct CelebrationParticles: View {
|
||||
// 动画粒子向上飘动
|
||||
withAnimation(.easeOut(duration: Double.random(in: 1.5...2.5))) {
|
||||
if let index = particles.firstIndex(where: { $0.id == particle.id }) {
|
||||
particles[index].position.y -= CGFloat.random(in: 300...500)
|
||||
particles[index].position.y -= CGFloat.random(in: flyDistance...(flyDistance * 1.5))
|
||||
particles[index].position.x += CGFloat.random(in: -50...50)
|
||||
particles[index].opacity = 0
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ struct SettingsView: View {
|
||||
@State private var feedbackPackageURL: URL?
|
||||
@State private var showingShareSheet = false
|
||||
@State private var showingLanguageChangeAlert = false
|
||||
@State private var showingFeedbackConfirmAlert = false
|
||||
@State private var pendingLanguage: LanguageManager.Language?
|
||||
|
||||
var body: some View {
|
||||
@@ -93,7 +94,7 @@ struct SettingsView: View {
|
||||
// 反馈
|
||||
Section {
|
||||
Button {
|
||||
exportFeedbackPackage()
|
||||
showingFeedbackConfirmAlert = true
|
||||
} label: {
|
||||
Label(String(localized: "settings.exportDiagnostics"), systemImage: "doc.text")
|
||||
}
|
||||
@@ -165,15 +166,22 @@ struct SettingsView: View {
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {
|
||||
pendingLanguage = nil
|
||||
}
|
||||
Button(String(localized: "settings.restartNow"), role: .none) {
|
||||
Button(String(localized: "settings.applyAndRestart"), role: .none) {
|
||||
if let newLanguage = pendingLanguage {
|
||||
LanguageManager.shared.current = newLanguage
|
||||
}
|
||||
exit(0)
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: "settings.languageChangeAlertMessage"))
|
||||
}
|
||||
.alert(String(localized: "settings.feedbackConfirmTitle"), isPresented: $showingFeedbackConfirmAlert) {
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
Button(String(localized: "settings.feedbackConfirmExport"), role: .none) {
|
||||
exportFeedbackPackage()
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: "settings.feedbackConfirmMessage"))
|
||||
}
|
||||
.sheet(isPresented: $showingShareSheet) {
|
||||
if let url = feedbackPackageURL {
|
||||
ShareSheet(activityItems: [url])
|
||||
@@ -407,12 +415,12 @@ struct SettingsView: View {
|
||||
extension PHAuthorizationStatus: @retroactive CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .notDetermined: return "未确定"
|
||||
case .restricted: return "受限"
|
||||
case .denied: return "已拒绝"
|
||||
case .authorized: return "已授权"
|
||||
case .limited: return "部分授权"
|
||||
@unknown default: return "未知"
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .authorized: return "authorized"
|
||||
case .limited: return "limited"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ struct WallpaperGuideView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxl) {
|
||||
headerSection
|
||||
|
||||
quickActionSection
|
||||
@@ -29,8 +29,8 @@ struct WallpaperGuideView: View {
|
||||
|
||||
doneButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, DesignTokens.Spacing.xl)
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
}
|
||||
.navigationTitle(String(localized: "wallpaper.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -95,17 +95,11 @@ struct WallpaperGuideView: View {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.title3)
|
||||
}
|
||||
.padding(16)
|
||||
.padding(DesignTokens.Spacing.lg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.blue, Color.purple],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.background(Color.gradientPrimary)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +166,7 @@ struct WallpaperGuideView: View {
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +227,7 @@ struct WallpaperGuideView: View {
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
|
||||
Text(String(localized: "wallpaper.canAlwaysCreate"))
|
||||
@@ -317,7 +311,7 @@ struct FAQRow: View {
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user