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)
|
# PyTorch models (use Core ML instead)
|
||||||
*.pth
|
*.pth
|
||||||
.serena/
|
.serena/
|
||||||
|
|
||||||
|
# AI coding tools
|
||||||
|
.agent/
|
||||||
|
.agents/
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
.cursor/
|
||||||
|
.gemini/
|
||||||
|
.kiro/
|
||||||
|
.opencode/
|
||||||
|
.qoder/
|
||||||
|
.trae/
|
||||||
|
.windsurf/
|
||||||
|
|||||||
@@ -157,9 +157,11 @@ public actor ODRManager {
|
|||||||
let request = NSBundleResourceRequest(tags: [Self.modelTag])
|
let request = NSBundleResourceRequest(tags: [Self.modelTag])
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
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 {
|
if available {
|
||||||
// Model is already downloaded via ODR
|
|
||||||
self.logger.debug("ODR model is available locally")
|
self.logger.debug("ODR model is available locally")
|
||||||
}
|
}
|
||||||
continuation.resume(returning: available)
|
continuation.resume(returning: available)
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ actor RealESRGANProcessor {
|
|||||||
|
|
||||||
logger.info("Running inference on \(width)x\(height) image...")
|
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
|
let output: [UInt8] = try await withCheckedThrowingContinuation { continuation in
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
do {
|
do {
|
||||||
@@ -123,22 +126,15 @@ actor RealESRGANProcessor {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Run inference synchronously
|
// Run inference synchronously
|
||||||
let prediction = try model.prediction(from: inputProvider)
|
let prediction = try localModel.prediction(from: inputProvider)
|
||||||
|
|
||||||
// Extract output from model
|
// Extract output from model
|
||||||
// The model outputs to "activation_out" as either MultiArray or Image
|
|
||||||
let rgbaData: [UInt8]
|
let rgbaData: [UInt8]
|
||||||
|
|
||||||
if let outputValue = prediction.featureValue(for: "activation_out") {
|
if let outputValue = prediction.featureValue(for: "activation_out") {
|
||||||
if let multiArray = outputValue.multiArrayValue {
|
if let multiArray = outputValue.multiArrayValue {
|
||||||
// Output is MLMultiArray with shape [C, H, W]
|
rgbaData = try Self.multiArrayToRGBA(multiArray)
|
||||||
self.logger.info("Output is MultiArray: \(multiArray.shape)")
|
|
||||||
rgbaData = try self.multiArrayToRGBA(multiArray)
|
|
||||||
} else if let outputBuffer = outputValue.imageBufferValue {
|
} 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)
|
rgbaData = try ImageFormatConverter.pixelBufferToRGBAData(outputBuffer)
|
||||||
} else {
|
} else {
|
||||||
continuation.resume(throwing: AIEnhanceError.inferenceError(
|
continuation.resume(throwing: AIEnhanceError.inferenceError(
|
||||||
@@ -162,13 +158,14 @@ actor RealESRGANProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Inference completed, output size: \(output.count) bytes")
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert MLMultiArray [C, H, W] to RGBA byte array
|
/// Convert MLMultiArray [C, H, W] to RGBA byte array
|
||||||
/// - Parameter multiArray: Output from model with shape [3, H, W] (RGB channels)
|
/// - Parameter multiArray: Output from model with shape [3, H, W] (RGB channels)
|
||||||
/// - Returns: RGBA byte array with shape [H * W * 4]
|
/// - 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 }
|
let shape = multiArray.shape.map { $0.intValue }
|
||||||
|
|
||||||
// Expect shape [3, H, W] for RGB
|
// Expect shape [3, H, W] for RGB
|
||||||
@@ -178,12 +175,9 @@ actor RealESRGANProcessor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let channels = shape[0]
|
|
||||||
let height = shape[1]
|
let height = shape[1]
|
||||||
let width = shape[2]
|
let width = shape[2]
|
||||||
|
|
||||||
logger.info("Converting MultiArray \(channels)x\(height)x\(width) to RGBA")
|
|
||||||
|
|
||||||
// Output array: RGBA format
|
// Output array: RGBA format
|
||||||
var rgbaData = [UInt8](repeating: 255, count: width * height * 4)
|
var rgbaData = [UInt8](repeating: 255, count: width * height * 4)
|
||||||
|
|
||||||
|
|||||||
@@ -63,17 +63,38 @@ struct TiledImageProcessor {
|
|||||||
logger.info("Extracted \(tiles.count) tiles")
|
logger.info("Extracted \(tiles.count) tiles")
|
||||||
progress?(0.1)
|
progress?(0.1)
|
||||||
|
|
||||||
// Step 2: Process each tile
|
// Step 2: Pre-allocate output buffers for streaming stitching
|
||||||
var processedTiles: [(tile: ImageTile, output: [UInt8])] = []
|
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 tileProgressBase = 0.1
|
||||||
let tileProgressRange = 0.7
|
let tileProgressRange = 0.75
|
||||||
|
|
||||||
for (index, tile) in tiles.enumerated() {
|
for (index, tile) in tiles.enumerated() {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
let pixelBuffer = try ImageFormatConverter.cgImageToPixelBuffer(tile.image)
|
let pixelBuffer = try ImageFormatConverter.cgImageToPixelBuffer(tile.image)
|
||||||
let outputData = try await processor.processImage(pixelBuffer)
|
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)
|
let tileProgress = tileProgressBase + tileProgressRange * Double(index + 1) / Double(tiles.count)
|
||||||
progress?(tileProgress)
|
progress?(tileProgress)
|
||||||
@@ -82,19 +103,14 @@ struct TiledImageProcessor {
|
|||||||
await Task.yield()
|
await Task.yield()
|
||||||
}
|
}
|
||||||
|
|
||||||
progress?(0.85)
|
progress?(0.9)
|
||||||
|
|
||||||
// Step 3: Stitch tiles with blending
|
// Step 4: Normalize and create final image
|
||||||
let outputWidth = originalWidth * config.modelScale
|
normalizeByWeights(&outputBuffer, weights: weightBuffer, width: outputWidth, height: outputHeight)
|
||||||
let outputHeight = originalHeight * config.modelScale
|
let stitchedImage = try createCGImage(from: outputBuffer, width: outputWidth, height: outputHeight)
|
||||||
let stitchedImage = try stitchTiles(
|
|
||||||
processedTiles,
|
|
||||||
outputWidth: outputWidth,
|
|
||||||
outputHeight: outputHeight
|
|
||||||
)
|
|
||||||
progress?(0.95)
|
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)
|
let finalImage = try capToMaxDimension(stitchedImage, maxDimension: 4320)
|
||||||
progress?(1.0)
|
progress?(1.0)
|
||||||
|
|
||||||
@@ -196,45 +212,6 @@ struct TiledImageProcessor {
|
|||||||
|
|
||||||
// MARK: - Tile Stitching
|
// 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
|
/// Create blending weights with linear falloff at edges
|
||||||
private func createBlendingWeights(tileWidth: Int, tileHeight: Int) -> [Float] {
|
private func createBlendingWeights(tileWidth: Int, tileHeight: Int) -> [Float] {
|
||||||
let overlap = config.outputOverlap // 256
|
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 actor LivePhotoValidator {
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
@@ -378,16 +393,13 @@ public actor LivePhotoValidator {
|
|||||||
|
|
||||||
public func requestLivePhoto(photoURL: URL, pairedVideoURL: URL) async -> PHLivePhoto? {
|
public func requestLivePhoto(photoURL: URL, pairedVideoURL: URL) async -> PHLivePhoto? {
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
var hasResumed = false
|
let resumeOnce = ResumeOnce()
|
||||||
let requestID = PHLivePhoto.request(
|
let requestID = PHLivePhoto.request(
|
||||||
withResourceFileURLs: [pairedVideoURL, photoURL],
|
withResourceFileURLs: [pairedVideoURL, photoURL],
|
||||||
placeholderImage: nil,
|
placeholderImage: nil,
|
||||||
targetSize: .zero,
|
targetSize: .zero,
|
||||||
contentMode: .aspectFit
|
contentMode: .aspectFit
|
||||||
) { livePhoto, info in
|
) { livePhoto, info in
|
||||||
// 确保只 resume 一次
|
|
||||||
guard !hasResumed else { return }
|
|
||||||
|
|
||||||
// 如果是降级版本,等待完整版本
|
// 如果是降级版本,等待完整版本
|
||||||
if let isDegraded = info[PHLivePhotoInfoIsDegradedKey] as? Bool, isDegraded {
|
if let isDegraded = info[PHLivePhotoInfoIsDegradedKey] as? Bool, isDegraded {
|
||||||
return
|
return
|
||||||
@@ -398,8 +410,9 @@ public actor LivePhotoValidator {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[LivePhotoValidator] requestLivePhoto error: \(error.localizedDescription)")
|
print("[LivePhotoValidator] requestLivePhoto error: \(error.localizedDescription)")
|
||||||
#endif
|
#endif
|
||||||
hasResumed = true
|
if resumeOnce.tryConsume() {
|
||||||
continuation.resume(returning: nil)
|
continuation.resume(returning: nil)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,23 +420,24 @@ public actor LivePhotoValidator {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[LivePhotoValidator] requestLivePhoto cancelled")
|
print("[LivePhotoValidator] requestLivePhoto cancelled")
|
||||||
#endif
|
#endif
|
||||||
hasResumed = true
|
if resumeOnce.tryConsume() {
|
||||||
continuation.resume(returning: nil)
|
continuation.resume(returning: nil)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hasResumed = true
|
if resumeOnce.tryConsume() {
|
||||||
continuation.resume(returning: livePhoto)
|
continuation.resume(returning: livePhoto)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加超时保护,防止无限等待
|
// 添加超时保护,防止无限等待
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||||
guard !hasResumed else { return }
|
guard resumeOnce.tryConsume() else { return }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[LivePhotoValidator] requestLivePhoto timeout, requestID: \(requestID)")
|
print("[LivePhotoValidator] requestLivePhoto timeout, requestID: \(requestID)")
|
||||||
#endif
|
#endif
|
||||||
PHLivePhoto.cancelRequest(withRequestID: requestID)
|
PHLivePhoto.cancelRequest(withRequestID: requestID)
|
||||||
hasResumed = true
|
|
||||||
continuation.resume(returning: nil)
|
continuation.resume(returning: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -966,6 +980,23 @@ public actor LivePhotoBuilder {
|
|||||||
assetWriter.startWriting()
|
assetWriter.startWriting()
|
||||||
videoReader.startReading()
|
videoReader.startReading()
|
||||||
metadataReader.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)
|
assetWriter.startSession(atSourceTime: .zero)
|
||||||
|
|
||||||
var currentFrameCount = 0
|
var currentFrameCount = 0
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 多语言快速截图脚本
|
# 多语言快速截图脚本
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
# 语言参数
|
# 语言参数
|
||||||
LANGUAGE=${1:-zh-Hans}
|
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"
|
mkdir -p "$SCREENSHOT_DIR"
|
||||||
|
|
||||||
COUNTER=1
|
COUNTER=1
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 将 6.9" 截图缩放为 6.5" 截图
|
# 将 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")
|
LANGUAGES=("ja" "zh-Hans" "zh-Hant" "en" "es" "ar" "fr" "ko")
|
||||||
|
|
||||||
echo "📐 开始缩放截图: 6.9\" (1320x2868) → 6.5\" (1284x2778)"
|
echo "📐 开始缩放截图: 6.9\" (1320x2868) → 6.5\" (1284x2778)"
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ final class AppState {
|
|||||||
self.currentWorkId = nil
|
self.currentWorkId = nil
|
||||||
self.currentProcessingTask = nil
|
self.currentProcessingTask = nil
|
||||||
self.processingProgress = nil
|
self.processingProgress = nil
|
||||||
|
self.pop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -84,6 +85,7 @@ final class AppState {
|
|||||||
currentWorkId = nil
|
currentWorkId = nil
|
||||||
currentProcessingTask = nil
|
currentProcessingTask = nil
|
||||||
processingProgress = nil
|
processingProgress = nil
|
||||||
|
pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
Analytics.shared.log(.buildLivePhotoCancel)
|
Analytics.shared.log(.buildLivePhotoCancel)
|
||||||
@@ -142,7 +144,7 @@ final class AppState {
|
|||||||
state.currentExportParams = nil
|
state.currentExportParams = nil
|
||||||
}
|
}
|
||||||
Analytics.shared.log(.buildLivePhotoSuccess)
|
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
|
return result
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
// 任务被取消,不需要额外处理
|
// 任务被取消,不需要额外处理
|
||||||
|
|||||||
@@ -530,19 +530,25 @@ struct SoftSlider: View {
|
|||||||
let gradient: LinearGradient
|
let gradient: LinearGradient
|
||||||
let accessibilityLabel: String
|
let accessibilityLabel: String
|
||||||
let step: Double
|
let step: Double
|
||||||
|
let onEditingChanged: ((Bool) -> Void)?
|
||||||
|
let isDisabled: Bool
|
||||||
|
|
||||||
init(
|
init(
|
||||||
value: Binding<Double>,
|
value: Binding<Double>,
|
||||||
in range: ClosedRange<Double>,
|
in range: ClosedRange<Double>,
|
||||||
step: Double = 0.1,
|
step: Double = 0.1,
|
||||||
gradient: LinearGradient = Color.gradientPrimary,
|
gradient: LinearGradient = Color.gradientPrimary,
|
||||||
accessibilityLabel: String = ""
|
accessibilityLabel: String = "",
|
||||||
|
isDisabled: Bool = false,
|
||||||
|
onEditingChanged: ((Bool) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self._value = value
|
self._value = value
|
||||||
self.range = range
|
self.range = range
|
||||||
self.step = step
|
self.step = step
|
||||||
self.gradient = gradient
|
self.gradient = gradient
|
||||||
self.accessibilityLabel = accessibilityLabel
|
self.accessibilityLabel = accessibilityLabel
|
||||||
|
self.isDisabled = isDisabled
|
||||||
|
self.onEditingChanged = onEditingChanged
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -560,7 +566,7 @@ struct SoftSlider: View {
|
|||||||
|
|
||||||
// 进度填充
|
// 进度填充
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(gradient)
|
.fill(isDisabled ? LinearGradient(colors: [Color.softPressed], startPoint: .leading, endPoint: .trailing) : gradient)
|
||||||
.frame(width: max(0, thumbX), height: 8)
|
.frame(width: max(0, thumbX), height: 8)
|
||||||
|
|
||||||
// 滑块
|
// 滑块
|
||||||
@@ -572,18 +578,25 @@ struct SoftSlider: View {
|
|||||||
.gesture(
|
.gesture(
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onChanged { gesture in
|
.onChanged { gesture in
|
||||||
|
guard !isDisabled else { return }
|
||||||
let newProgress = gesture.location.x / width
|
let newProgress = gesture.location.x / width
|
||||||
let clampedProgress = max(0, min(1, newProgress))
|
let clampedProgress = max(0, min(1, newProgress))
|
||||||
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
|
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
|
||||||
|
onEditingChanged?(true)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
onEditingChanged?(false)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.opacity(isDisabled ? 0.5 : 1.0)
|
||||||
}
|
}
|
||||||
.frame(height: 28)
|
.frame(height: 28)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel)
|
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel)
|
||||||
.accessibilityValue(Text(String(format: "%.1f", value)))
|
.accessibilityValue(Text(String(format: "%.1f", value)))
|
||||||
.accessibilityAdjustableAction { direction in
|
.accessibilityAdjustableAction { direction in
|
||||||
|
guard !isDisabled else { return }
|
||||||
switch direction {
|
switch direction {
|
||||||
case .increment:
|
case .increment:
|
||||||
value = min(range.upperBound, value + step)
|
value = min(range.upperBound, value + step)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ final class LanguageManager {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .system: return "跟随系统"
|
case .system: return String(localized: "settings.language.system")
|
||||||
case .zhHans: return "简体中文"
|
case .zhHans: return "简体中文"
|
||||||
case .zhHant: return "繁體中文"
|
case .zhHant: return "繁體中文"
|
||||||
case .en: return "English"
|
case .en: return "English"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -241,9 +241,9 @@ struct EditorView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 封面帧预览
|
// MARK: - 封面帧预览
|
||||||
@@ -289,9 +289,9 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 时长控制
|
// MARK: - 时长控制
|
||||||
@@ -310,18 +310,25 @@ struct EditorView: View {
|
|||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider(value: $trimEnd, in: 1.0...max(1.0, min(1.5, videoDuration))) { _ in
|
SoftSlider(
|
||||||
updateKeyFrameTime()
|
value: $trimEnd,
|
||||||
}
|
in: 1.0...max(1.0, min(1.5, videoDuration)),
|
||||||
.disabled(videoDuration < 1.0)
|
step: 0.1,
|
||||||
|
gradient: Color.gradientPrimary,
|
||||||
|
accessibilityLabel: String(localized: "editor.videoDuration"),
|
||||||
|
isDisabled: videoDuration < 1.0,
|
||||||
|
onEditingChanged: { _ in
|
||||||
|
updateKeyFrameTime()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
Text(String(localized: "editor.durationHint"))
|
Text(String(localized: "editor.durationHint"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 封面帧时间选择
|
// MARK: - 封面帧时间选择
|
||||||
@@ -340,19 +347,26 @@ struct EditorView: View {
|
|||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider(value: $keyFrameTime, in: trimStart...max(trimStart + 0.1, trimEnd)) { editing in
|
SoftSlider(
|
||||||
if !editing {
|
value: $keyFrameTime,
|
||||||
extractCoverFrame()
|
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"))
|
Text(String(localized: "editor.keyFrameHint"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AI 超分辨率开关
|
// MARK: - AI 超分辨率开关
|
||||||
@@ -362,7 +376,7 @@ struct EditorView: View {
|
|||||||
Toggle(isOn: $aiEnhanceEnabled) {
|
Toggle(isOn: $aiEnhanceEnabled) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "wand.and.stars.inverse")
|
Image(systemName: "wand.and.stars.inverse")
|
||||||
.foregroundStyle(.purple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(String(localized: "editor.aiEnhance"))
|
Text(String(localized: "editor.aiEnhance"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -372,7 +386,7 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(.purple)
|
.tint(Color.accentPurple)
|
||||||
.disabled(!AIEnhancer.isAvailable() || aiModelDownloading)
|
.disabled(!AIEnhancer.isAvailable() || aiModelDownloading)
|
||||||
.onChange(of: aiEnhanceEnabled) { _, newValue in
|
.onChange(of: aiEnhanceEnabled) { _, newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
@@ -392,7 +406,7 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProgressView(value: aiModelDownloadProgress)
|
ProgressView(value: aiModelDownloadProgress)
|
||||||
.tint(.purple)
|
.tint(Color.accentPurple)
|
||||||
|
|
||||||
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
|
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
@@ -414,21 +428,21 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.foregroundStyle(.purple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.aiResolutionBoost"))
|
Text(String(localized: "editor.aiResolutionBoost"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "clock")
|
Image(systemName: "clock")
|
||||||
.foregroundStyle(.purple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.aiProcessingTime"))
|
Text(String(localized: "editor.aiProcessingTime"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "cpu")
|
Image(systemName: "cpu")
|
||||||
.foregroundStyle(.purple)
|
.foregroundStyle(Color.accentPurple)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(String(localized: "editor.aiLocalProcessing"))
|
Text(String(localized: "editor.aiLocalProcessing"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -450,9 +464,9 @@ struct EditorView: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.purple.opacity(0.1))
|
.background(Color.accentPurple.opacity(0.1))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
.task {
|
.task {
|
||||||
// 检查模型是否需要下载
|
// 检查模型是否需要下载
|
||||||
aiModelNeedsDownload = await AIEnhancer.needsDownload()
|
aiModelNeedsDownload = await AIEnhancer.needsDownload()
|
||||||
@@ -513,9 +527,9 @@ struct EditorView: View {
|
|||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 诊断建议
|
// MARK: - 诊断建议
|
||||||
@@ -561,9 +575,9 @@ struct EditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.background(Color.yellow.opacity(0.1))
|
.background(Color.accentOrange.opacity(0.1))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 生成按钮
|
// MARK: - 生成按钮
|
||||||
|
|||||||
@@ -72,11 +72,11 @@ struct HomeView: View {
|
|||||||
|
|
||||||
VStack(spacing: DesignTokens.Spacing.sm) {
|
VStack(spacing: DesignTokens.Spacing.sm) {
|
||||||
Text(String(localized: "home.title"))
|
Text(String(localized: "home.title"))
|
||||||
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
|
.font(.title2.bold())
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
|
|
||||||
Text(String(localized: "home.subtitle"))
|
Text(String(localized: "home.subtitle"))
|
||||||
.font(.system(size: DesignTokens.FontSize.base))
|
.font(.subheadline)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ struct HomeView: View {
|
|||||||
Image(systemName: "video.badge.plus")
|
Image(systemName: "video.badge.plus")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 18, weight: .semibold))
|
||||||
Text(String(localized: "home.selectVideo"))
|
Text(String(localized: "home.selectVideo"))
|
||||||
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -102,9 +102,6 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(HomeButtonStyle())
|
.buttonStyle(HomeButtonStyle())
|
||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
.onChange(of: selectedItem) { _, _ in
|
|
||||||
Analytics.shared.log(.homeImportVideoClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
if isLoading {
|
if isLoading {
|
||||||
@@ -112,7 +109,7 @@ struct HomeView: View {
|
|||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(.accentPurple)
|
.tint(.accentPurple)
|
||||||
Text(String(localized: "home.loading"))
|
Text(String(localized: "home.loading"))
|
||||||
.font(.system(size: DesignTokens.FontSize.sm))
|
.font(.footnote)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(.top, DesignTokens.Spacing.sm)
|
.padding(.top, DesignTokens.Spacing.sm)
|
||||||
@@ -124,7 +121,7 @@ struct HomeView: View {
|
|||||||
Image(systemName: "exclamationmark.circle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
.foregroundColor(.accentOrange)
|
.foregroundColor(.accentOrange)
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
.font(.system(size: DesignTokens.FontSize.sm))
|
.font(.footnote)
|
||||||
.foregroundColor(.accentOrange)
|
.foregroundColor(.accentOrange)
|
||||||
}
|
}
|
||||||
.padding(.top, DesignTokens.Spacing.sm)
|
.padding(.top, DesignTokens.Spacing.sm)
|
||||||
@@ -150,7 +147,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(String(localized: "home.quickStart"))
|
Text(String(localized: "home.quickStart"))
|
||||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
.font(.headline)
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -166,7 +163,7 @@ struct HomeView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(localized: "home.emptyHint"))
|
Text(String(localized: "home.emptyHint"))
|
||||||
.font(.system(size: DesignTokens.FontSize.xs))
|
.font(.caption2)
|
||||||
.foregroundColor(.textMuted)
|
.foregroundColor(.textMuted)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -191,13 +188,13 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(String(localized: "home.recentWorks"))
|
Text(String(localized: "home.recentWorks"))
|
||||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
.font(.headline)
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text(String(localized: "home.worksCount \(recentWorks.recentWorks.count)"))
|
Text(String(localized: "home.worksCount \(recentWorks.recentWorks.count)"))
|
||||||
.font(.system(size: DesignTokens.FontSize.sm))
|
.font(.footnote)
|
||||||
.foregroundColor(.textMuted)
|
.foregroundColor(.textMuted)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, DesignTokens.Spacing.xs)
|
.padding(.horizontal, DesignTokens.Spacing.xs)
|
||||||
@@ -219,6 +216,7 @@ struct HomeView: View {
|
|||||||
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
|
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
|
||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
|
|
||||||
|
Analytics.shared.log(.homeImportVideoClick)
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
@@ -254,12 +252,12 @@ struct QuickStartStep: View {
|
|||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
Text("\(number)")
|
Text("\(number)")
|
||||||
.font(.system(size: DesignTokens.FontSize.xs, weight: .bold))
|
.font(.caption2.bold())
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: DesignTokens.FontSize.sm))
|
.font(.footnote)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -336,11 +334,11 @@ struct RecentWorkCard: View {
|
|||||||
// 信息
|
// 信息
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(work.aspectRatioDisplayName)
|
Text(work.aspectRatioDisplayName)
|
||||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .medium))
|
.font(.footnote.weight(.medium))
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
|
|
||||||
Text(work.createdAt.formatted(.relative(presentation: .named)))
|
Text(work.createdAt.formatted(.relative(presentation: .named)))
|
||||||
.font(.system(size: DesignTokens.FontSize.xs))
|
.font(.caption2)
|
||||||
.foregroundColor(.textMuted)
|
.foregroundColor(.textMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,11 +105,11 @@ struct OnboardingView: View {
|
|||||||
// 文字内容
|
// 文字内容
|
||||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||||
Text(page.title)
|
Text(page.title)
|
||||||
.font(.system(size: DesignTokens.FontSize.xxxl, weight: .bold))
|
.font(.title.bold())
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
|
|
||||||
Text(page.description)
|
Text(page.description)
|
||||||
.font(.system(size: DesignTokens.FontSize.base))
|
.font(.subheadline)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.lineSpacing(4)
|
.lineSpacing(4)
|
||||||
@@ -148,7 +148,7 @@ struct OnboardingView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||||
Text(currentPage < pages.count - 1 ? String(localized: "onboarding.nextStep") : String(localized: "onboarding.getStarted"))
|
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")
|
Image(systemName: currentPage < pages.count - 1 ? "arrow.right" : "checkmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
@@ -168,8 +168,8 @@ struct OnboardingView: View {
|
|||||||
Button {
|
Button {
|
||||||
completeOnboarding()
|
completeOnboarding()
|
||||||
} label: {
|
} label: {
|
||||||
Text("跳过")
|
Text(String(localized: "onboarding.skip"))
|
||||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .medium))
|
.font(.footnote.weight(.medium))
|
||||||
.foregroundColor(.textMuted)
|
.foregroundColor(.textMuted)
|
||||||
}
|
}
|
||||||
.padding(.top, DesignTokens.Spacing.sm)
|
.padding(.top, DesignTokens.Spacing.sm)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct PrivacyPolicyView: View {
|
|||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
Text("privacy.lastUpdated")
|
Text("privacy.lastUpdated")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
sectionHeader(String(localized: "privacy.overview.title"))
|
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.item1.title"), String(localized: "privacy.localMode.item1.desc"))
|
||||||
bulletPoint(String(localized: "privacy.localMode.item2.title"), String(localized: "privacy.localMode.item2.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"))
|
sectionHeader(String(localized: "privacy.permissions.title"))
|
||||||
bulletPoint(String(localized: "privacy.permissions.item1.title"), String(localized: "privacy.permissions.item1.desc"))
|
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"))
|
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"))
|
sectionHeader(String(localized: "privacy.storage.title"))
|
||||||
bulletPoint(String(localized: "privacy.storage.item1.title"), String(localized: "privacy.storage.item1.desc"))
|
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.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"))
|
sectionHeader(String(localized: "privacy.thirdParty.title"))
|
||||||
bulletPoint(String(localized: "privacy.thirdParty.item1.title"), String(localized: "privacy.thirdParty.item1.desc"))
|
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"))
|
sectionHeader(String(localized: "privacy.contact.title"))
|
||||||
Text("privacy.contact.intro")
|
Text("privacy.contact.intro")
|
||||||
Text("privacy.contact.email")
|
Text("privacy.contact.email")
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(Color.accentPurple)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
@@ -76,12 +68,12 @@ struct PrivacyPolicyView: View {
|
|||||||
private func bulletPoint(_ title: String, _ description: String) -> some View {
|
private func bulletPoint(_ title: String, _ description: String) -> some View {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Text("•")
|
Text("•")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.textSecondary)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Text(description)
|
Text(description)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +85,7 @@ struct TermsOfServiceView: View {
|
|||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
Text("terms.lastUpdated")
|
Text("terms.lastUpdated")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
sectionHeader(String(localized: "terms.acceptance.title"))
|
sectionHeader(String(localized: "terms.acceptance.title"))
|
||||||
@@ -103,21 +95,11 @@ struct TermsOfServiceView: View {
|
|||||||
Text("terms.service.desc1")
|
Text("terms.service.desc1")
|
||||||
Text("terms.service.desc2")
|
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"))
|
sectionHeader(String(localized: "terms.limits.title"))
|
||||||
bulletPoint(String(localized: "terms.limits.item1.title"), String(localized: "terms.limits.item1.desc"))
|
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.item2.title"), String(localized: "terms.limits.item2.desc"))
|
||||||
bulletPoint(String(localized: "terms.limits.item3.title"), String(localized: "terms.limits.item3.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"))
|
sectionHeader(String(localized: "terms.disclaimer.title"))
|
||||||
bulletPoint(String(localized: "terms.disclaimer.item1.title"), String(localized: "terms.disclaimer.item1.desc"))
|
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"))
|
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"))
|
sectionHeader(String(localized: "terms.contact.title"))
|
||||||
Text("terms.contact.email")
|
Text("terms.contact.email")
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(Color.accentPurple)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
@@ -150,12 +132,12 @@ struct TermsOfServiceView: View {
|
|||||||
private func bulletPoint(_ title: String, _ description: String) -> some View {
|
private func bulletPoint(_ title: String, _ description: String) -> some View {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Text("•")
|
Text("•")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.textSecondary)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Text(description)
|
Text(description)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ struct ProcessingView: View {
|
|||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button(String(localized: "processing.cancel")) {
|
Button(String(localized: "processing.cancel")) {
|
||||||
appState.cancelProcessing()
|
appState.cancelProcessing()
|
||||||
appState.pop()
|
|
||||||
}
|
}
|
||||||
.foregroundColor(.accentPurple)
|
.foregroundColor(.accentPurple)
|
||||||
}
|
}
|
||||||
@@ -78,7 +77,7 @@ struct ProcessingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(String(localized: "processing.cancelling"))
|
Text(String(localized: "processing.cancelling"))
|
||||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
.font(.headline)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -109,7 +108,7 @@ struct ProcessingView: View {
|
|||||||
|
|
||||||
if let progress = appState.processingProgress {
|
if let progress = appState.processingProgress {
|
||||||
Text(String(format: "%.0f%%", progress.fraction * 100))
|
Text(String(format: "%.0f%%", progress.fraction * 100))
|
||||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .bold))
|
.font(.headline.bold())
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
}
|
}
|
||||||
@@ -119,13 +118,13 @@ struct ProcessingView: View {
|
|||||||
// 阶段信息
|
// 阶段信息
|
||||||
VStack(spacing: DesignTokens.Spacing.md) {
|
VStack(spacing: DesignTokens.Spacing.md) {
|
||||||
Text(stageText)
|
Text(stageText)
|
||||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
.font(.headline)
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
.animation(.easeInOut(duration: 0.3), value: stageText)
|
.animation(.easeInOut(duration: 0.3), value: stageText)
|
||||||
|
|
||||||
Text(stageDescription)
|
Text(stageDescription)
|
||||||
.font(.system(size: DesignTokens.FontSize.sm))
|
.font(.footnote)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
@@ -162,11 +161,11 @@ struct ProcessingView: View {
|
|||||||
if let error = appState.processingError {
|
if let error = appState.processingError {
|
||||||
VStack(spacing: DesignTokens.Spacing.md) {
|
VStack(spacing: DesignTokens.Spacing.md) {
|
||||||
Text(String(localized: "processing.failed"))
|
Text(String(localized: "processing.failed"))
|
||||||
.font(.system(size: DesignTokens.FontSize.xl, weight: .bold))
|
.font(.title3.bold())
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
|
|
||||||
Text(error.message)
|
Text(error.message)
|
||||||
.font(.system(size: DesignTokens.FontSize.base))
|
.font(.subheadline)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
@@ -177,7 +176,7 @@ struct ProcessingView: View {
|
|||||||
Image(systemName: "lightbulb.fill")
|
Image(systemName: "lightbulb.fill")
|
||||||
.foregroundColor(.accentOrange)
|
.foregroundColor(.accentOrange)
|
||||||
Text(String(localized: "processing.suggestions"))
|
Text(String(localized: "processing.suggestions"))
|
||||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .semibold))
|
.font(.footnote.weight(.semibold))
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +186,7 @@ struct ProcessingView: View {
|
|||||||
.fill(Color.accentOrange)
|
.fill(Color.accentOrange)
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
Text(action)
|
Text(action)
|
||||||
.font(.system(size: DesignTokens.FontSize.sm))
|
.font(.footnote)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,12 +100,12 @@ struct ResultView: View {
|
|||||||
private var resultInfo: some View {
|
private var resultInfo: some View {
|
||||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||||
Text(isSuccess ? String(localized: "result.saved") : String(localized: "result.saveFailed"))
|
Text(isSuccess ? String(localized: "result.saved") : String(localized: "result.saveFailed"))
|
||||||
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
|
.font(.title2.bold())
|
||||||
.foregroundColor(.textPrimary)
|
.foregroundColor(.textPrimary)
|
||||||
|
|
||||||
if isSuccess {
|
if isSuccess {
|
||||||
Text(String(localized: "result.savedDescription"))
|
Text(String(localized: "result.savedDescription"))
|
||||||
.font(.system(size: DesignTokens.FontSize.base))
|
.font(.subheadline)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ struct ResultView: View {
|
|||||||
.padding(.top, DesignTokens.Spacing.sm)
|
.padding(.top, DesignTokens.Spacing.sm)
|
||||||
} else {
|
} else {
|
||||||
Text(String(localized: "result.failedDescription"))
|
Text(String(localized: "result.failedDescription"))
|
||||||
.font(.system(size: DesignTokens.FontSize.base))
|
.font(.subheadline)
|
||||||
.foregroundColor(.textSecondary)
|
.foregroundColor(.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ struct ResultView: View {
|
|||||||
appState.popToRoot()
|
appState.popToRoot()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SoftPrimaryButton("返回重试", icon: "arrow.counterclockwise", gradient: Color.gradientWarm) {
|
SoftPrimaryButton(String(localized: "result.backToRetry"), icon: "arrow.counterclockwise", gradient: Color.gradientWarm) {
|
||||||
appState.pop()
|
appState.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ struct ValidationBadge: View {
|
|||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: DesignTokens.FontSize.xs, weight: .medium))
|
.font(.caption2.weight(.medium))
|
||||||
}
|
}
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.horizontal, DesignTokens.Spacing.md)
|
.padding(.horizontal, DesignTokens.Spacing.md)
|
||||||
@@ -212,6 +212,7 @@ struct ValidationBadge: View {
|
|||||||
// MARK: - 庆祝粒子效果
|
// MARK: - 庆祝粒子效果
|
||||||
struct CelebrationParticles: View {
|
struct CelebrationParticles: View {
|
||||||
@State private var particles: [Particle] = []
|
@State private var particles: [Particle] = []
|
||||||
|
@State private var viewSize: CGSize = .zero
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
@@ -222,22 +223,30 @@ struct CelebrationParticles: View {
|
|||||||
.position(particle.position)
|
.position(particle.position)
|
||||||
.opacity(particle.opacity)
|
.opacity(particle.opacity)
|
||||||
}
|
}
|
||||||
}
|
.onAppear {
|
||||||
.onAppear {
|
viewSize = geometry.size
|
||||||
generateParticles()
|
generateParticles(in: geometry.size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func generateParticles() {
|
private func generateParticles(in size: CGSize) {
|
||||||
let colors: [Color] = [.accentPurple, .accentPink, .accentGreen, .accentCyan, .accentOrange]
|
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 {
|
for i in 0..<30 {
|
||||||
let delay = Double(i) * 0.03
|
let delay = Double(i) * 0.03
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
let particle = Particle(
|
let particle = Particle(
|
||||||
id: UUID(),
|
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()!,
|
color: colors.randomElement()!,
|
||||||
size: CGFloat.random(in: 6...12),
|
size: CGFloat.random(in: 6...12),
|
||||||
opacity: 1.0
|
opacity: 1.0
|
||||||
@@ -247,7 +256,7 @@ struct CelebrationParticles: View {
|
|||||||
// 动画粒子向上飘动
|
// 动画粒子向上飘动
|
||||||
withAnimation(.easeOut(duration: Double.random(in: 1.5...2.5))) {
|
withAnimation(.easeOut(duration: Double.random(in: 1.5...2.5))) {
|
||||||
if let index = particles.firstIndex(where: { $0.id == particle.id }) {
|
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].position.x += CGFloat.random(in: -50...50)
|
||||||
particles[index].opacity = 0
|
particles[index].opacity = 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct SettingsView: View {
|
|||||||
@State private var feedbackPackageURL: URL?
|
@State private var feedbackPackageURL: URL?
|
||||||
@State private var showingShareSheet = false
|
@State private var showingShareSheet = false
|
||||||
@State private var showingLanguageChangeAlert = false
|
@State private var showingLanguageChangeAlert = false
|
||||||
|
@State private var showingFeedbackConfirmAlert = false
|
||||||
@State private var pendingLanguage: LanguageManager.Language?
|
@State private var pendingLanguage: LanguageManager.Language?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -93,7 +94,7 @@ struct SettingsView: View {
|
|||||||
// 反馈
|
// 反馈
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
exportFeedbackPackage()
|
showingFeedbackConfirmAlert = true
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "settings.exportDiagnostics"), systemImage: "doc.text")
|
Label(String(localized: "settings.exportDiagnostics"), systemImage: "doc.text")
|
||||||
}
|
}
|
||||||
@@ -165,15 +166,22 @@ struct SettingsView: View {
|
|||||||
Button(String(localized: "common.cancel"), role: .cancel) {
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
||||||
pendingLanguage = nil
|
pendingLanguage = nil
|
||||||
}
|
}
|
||||||
Button(String(localized: "settings.restartNow"), role: .none) {
|
Button(String(localized: "settings.applyAndRestart"), role: .none) {
|
||||||
if let newLanguage = pendingLanguage {
|
if let newLanguage = pendingLanguage {
|
||||||
LanguageManager.shared.current = newLanguage
|
LanguageManager.shared.current = newLanguage
|
||||||
}
|
}
|
||||||
exit(0)
|
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text(String(localized: "settings.languageChangeAlertMessage"))
|
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) {
|
.sheet(isPresented: $showingShareSheet) {
|
||||||
if let url = feedbackPackageURL {
|
if let url = feedbackPackageURL {
|
||||||
ShareSheet(activityItems: [url])
|
ShareSheet(activityItems: [url])
|
||||||
@@ -407,12 +415,12 @@ struct SettingsView: View {
|
|||||||
extension PHAuthorizationStatus: @retroactive CustomStringConvertible {
|
extension PHAuthorizationStatus: @retroactive CustomStringConvertible {
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .notDetermined: return "未确定"
|
case .notDetermined: return "notDetermined"
|
||||||
case .restricted: return "受限"
|
case .restricted: return "restricted"
|
||||||
case .denied: return "已拒绝"
|
case .denied: return "denied"
|
||||||
case .authorized: return "已授权"
|
case .authorized: return "authorized"
|
||||||
case .limited: return "部分授权"
|
case .limited: return "limited"
|
||||||
@unknown default: return "未知"
|
@unknown default: return "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct WallpaperGuideView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxl) {
|
||||||
headerSection
|
headerSection
|
||||||
|
|
||||||
quickActionSection
|
quickActionSection
|
||||||
@@ -29,8 +29,8 @@ struct WallpaperGuideView: View {
|
|||||||
|
|
||||||
doneButton
|
doneButton
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, DesignTokens.Spacing.xl)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||||
}
|
}
|
||||||
.navigationTitle(String(localized: "wallpaper.title"))
|
.navigationTitle(String(localized: "wallpaper.title"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -95,17 +95,11 @@ struct WallpaperGuideView: View {
|
|||||||
Image(systemName: "arrow.up.right.square")
|
Image(systemName: "arrow.up.right.square")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(DesignTokens.Spacing.lg)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(
|
.background(Color.gradientPrimary)
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.blue, Color.purple],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +166,7 @@ struct WallpaperGuideView: View {
|
|||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +227,7 @@ struct WallpaperGuideView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.background(Color.accentColor)
|
.background(Color.accentColor)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(String(localized: "wallpaper.canAlwaysCreate"))
|
Text(String(localized: "wallpaper.canAlwaysCreate"))
|
||||||
@@ -317,7 +311,7 @@ struct FAQRow: View {
|
|||||||
.padding(14)
|
.padding(14)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.softElevated)
|
.background(Color.softElevated)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user