feat: M2-M4 完成,添加 AI 增强、设计系统、App Store 准备
新增功能: - AI 超分辨率模块 (Real-ESRGAN Core ML) - Soft UI 设计系统 (DesignSystem.swift) - 设置页、隐私政策页、引导页 - 最近作品管理器 App Store 准备: - 完善截图 (iPhone 6.7"/6.5", iPad 12.9") - App Store 元数据文档 - 修复应用图标 alpha 通道 - 更新显示名称为 Live Photo Studio 工程配置: - 配置 Git LFS 跟踪 mlmodel 文件 - 添加 Claude skill 开发指南 - 更新 .gitignore 规则 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -177,7 +177,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2610;
|
||||
LastUpgradeCheck = 2610;
|
||||
LastUpgradeCheck = 2620;
|
||||
TargetAttributes = {
|
||||
F1A6CF4E2EED942500822C1B = {
|
||||
CreatedOnToolsVersion = 26.1.1;
|
||||
@@ -337,6 +337,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -394,6 +395,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
@@ -409,6 +411,7 @@
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Live Photo Studio";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于将生成的 Live Photo 保存到系统相册";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "用于读取并校验已保存的 Live Photo(可选)";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -422,7 +425,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "xu.to-live-photo";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomaker;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
@@ -444,6 +447,7 @@
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Live Photo Studio";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于将生成的 Live Photo 保存到系统相册";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "用于读取并校验已保存的 Live Photo(可选)";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -457,7 +461,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "xu.to-live-photo";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomaker;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
@@ -479,7 +483,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "xu.to-live-photoTests";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomakerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
@@ -501,7 +505,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "xu.to-live-photoTests";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomakerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
@@ -521,7 +525,7 @@
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "xu.to-live-photoUITests";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomakerUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
@@ -541,7 +545,7 @@
|
||||
DEVELOPMENT_TEAM = Y976PBNGA8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "xu.to-live-photoUITests";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.let5see.livephotomakerUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
// to-live-photo
|
||||
//
|
||||
// 基础埋点模块(MVP 版:仅打印日志,后续接入 SDK)
|
||||
// M3 增强:日志脱敏、错误归因统计
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
import LivePhotoCore
|
||||
|
||||
/// 埋点事件枚举
|
||||
enum AnalyticsEvent: String {
|
||||
@@ -34,20 +36,80 @@ enum AnalyticsEvent: String {
|
||||
case guideComplete = "guide_complete"
|
||||
}
|
||||
|
||||
/// 错误统计数据(按 stage 归类)
|
||||
struct ErrorStats: Codable {
|
||||
var totalErrors: Int = 0
|
||||
var errorsByStage: [String: Int] = [:]
|
||||
var errorsByCode: [String: Int] = [:]
|
||||
var lastError: String?
|
||||
var lastErrorTime: Date?
|
||||
|
||||
mutating func record(code: String, stage: String?) {
|
||||
totalErrors += 1
|
||||
errorsByCode[code, default: 0] += 1
|
||||
if let stage {
|
||||
errorsByStage[stage, default: 0] += 1
|
||||
}
|
||||
lastError = code
|
||||
lastErrorTime = Date()
|
||||
}
|
||||
}
|
||||
|
||||
/// 埋点管理器(MVP 版:打印日志)
|
||||
@MainActor
|
||||
final class Analytics {
|
||||
static let shared = Analytics()
|
||||
|
||||
private let logger = Logger(subsystem: "ToLivePhoto", category: "Analytics")
|
||||
private let statsKey = "com.tolivePhoto.errorStats"
|
||||
|
||||
private init() {}
|
||||
/// 错误统计
|
||||
private(set) var errorStats: ErrorStats {
|
||||
didSet {
|
||||
saveStats()
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
errorStats = Self.loadStats(key: statsKey)
|
||||
}
|
||||
|
||||
// MARK: - 日志脱敏
|
||||
|
||||
/// 脱敏处理:移除敏感路径信息
|
||||
private func sanitize(_ value: Any) -> Any {
|
||||
if let string = value as? String {
|
||||
return sanitizePath(string)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// 脱敏文件路径:隐藏用户目录
|
||||
private func sanitizePath(_ path: String) -> String {
|
||||
// 替换用户目录路径
|
||||
var sanitized = path
|
||||
if let homeDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.deletingLastPathComponent().path {
|
||||
sanitized = sanitized.replacingOccurrences(of: homeDir, with: "~")
|
||||
}
|
||||
// 替换临时目录
|
||||
sanitized = sanitized.replacingOccurrences(of: NSTemporaryDirectory(), with: "{tmp}/")
|
||||
// 替换 UUID 样式的字符串(可能包含敏感 ID)
|
||||
let uuidPattern = "[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}"
|
||||
if let regex = try? NSRegularExpression(pattern: uuidPattern, options: .caseInsensitive) {
|
||||
let range = NSRange(sanitized.startIndex..., in: sanitized)
|
||||
sanitized = regex.stringByReplacingMatches(in: sanitized, options: [], range: range, withTemplate: "{uuid}")
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// MARK: - 事件记录
|
||||
|
||||
/// 记录事件
|
||||
func log(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) {
|
||||
var logMessage = "[\(event.rawValue)]"
|
||||
if let parameters {
|
||||
let paramsString = parameters.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
|
||||
let sanitizedParams = parameters.mapValues { sanitize($0) }
|
||||
let paramsString = sanitizedParams.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
|
||||
logMessage += " {\(paramsString)}"
|
||||
}
|
||||
logger.info("\(logMessage, privacy: .public)")
|
||||
@@ -57,10 +119,93 @@ final class Analytics {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 记录错误事件
|
||||
/// 记录错误事件(带归因统计)
|
||||
func logError(_ event: AnalyticsEvent, error: Error, parameters: [String: Any]? = nil) {
|
||||
var params = parameters ?? [:]
|
||||
params["error"] = error.localizedDescription
|
||||
params["error"] = sanitize(error.localizedDescription)
|
||||
|
||||
// 如果是 AppError,提取 stage 信息用于归因
|
||||
if let appError = error as? AppError {
|
||||
params["code"] = appError.code
|
||||
if let stage = appError.stage {
|
||||
params["stage"] = stage.rawValue
|
||||
}
|
||||
// 记录到统计
|
||||
errorStats.record(code: appError.code, stage: appError.stage?.rawValue)
|
||||
} else {
|
||||
// 通用错误
|
||||
let code = "UNKNOWN"
|
||||
errorStats.record(code: code, stage: nil)
|
||||
}
|
||||
|
||||
log(event, parameters: params)
|
||||
}
|
||||
|
||||
/// 记录 AppError 事件
|
||||
func logAppError(_ event: AnalyticsEvent, appError: AppError) {
|
||||
var params: [String: Any] = [
|
||||
"code": appError.code,
|
||||
"message": sanitize(appError.message)
|
||||
]
|
||||
if let stage = appError.stage {
|
||||
params["stage"] = stage.rawValue
|
||||
}
|
||||
if let underlying = appError.underlyingErrorDescription {
|
||||
params["underlying"] = sanitize(underlying)
|
||||
}
|
||||
|
||||
// 记录到统计
|
||||
errorStats.record(code: appError.code, stage: appError.stage?.rawValue)
|
||||
|
||||
log(event, parameters: params)
|
||||
}
|
||||
|
||||
// MARK: - 统计管理
|
||||
|
||||
/// 获取错误统计摘要(用于诊断报告)
|
||||
func getErrorStatsSummary() -> String {
|
||||
var summary = "Error Statistics:\n"
|
||||
summary += " Total errors: \(errorStats.totalErrors)\n"
|
||||
|
||||
if !errorStats.errorsByStage.isEmpty {
|
||||
summary += " By stage:\n"
|
||||
for (stage, count) in errorStats.errorsByStage.sorted(by: { $0.value > $1.value }) {
|
||||
summary += " \(stage): \(count)\n"
|
||||
}
|
||||
}
|
||||
|
||||
if !errorStats.errorsByCode.isEmpty {
|
||||
summary += " By code:\n"
|
||||
for (code, count) in errorStats.errorsByCode.sorted(by: { $0.value > $1.value }) {
|
||||
summary += " \(code): \(count)\n"
|
||||
}
|
||||
}
|
||||
|
||||
if let lastError = errorStats.lastError, let lastTime = errorStats.lastErrorTime {
|
||||
summary += " Last error: \(lastError) at \(lastTime.formatted())\n"
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/// 重置错误统计
|
||||
func resetErrorStats() {
|
||||
errorStats = ErrorStats()
|
||||
}
|
||||
|
||||
// MARK: - 持久化
|
||||
|
||||
private func saveStats() {
|
||||
if let data = try? JSONEncoder().encode(errorStats) {
|
||||
UserDefaults.standard.set(data, forKey: statsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadStats(key: String) -> ErrorStats {
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let stats = try? JSONDecoder().decode(ErrorStats.self, from: data) else {
|
||||
return ErrorStats()
|
||||
}
|
||||
return stats
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ enum AppRoute: Hashable {
|
||||
case processing(videoURL: URL, exportParams: ExportParams)
|
||||
case result(workflowResult: LivePhotoWorkflowResult)
|
||||
case wallpaperGuide(assetId: String)
|
||||
case settings
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -27,6 +28,9 @@ final class AppState {
|
||||
var isProcessing = false
|
||||
var isCancelling = false
|
||||
|
||||
/// 当前处理中的导出参数(用于保存最近作品时记录)
|
||||
private var currentExportParams: ExportParams?
|
||||
|
||||
private var workflow: LivePhotoWorkflow?
|
||||
private var currentProcessingTask: Task<LivePhotoWorkflowResult?, Never>?
|
||||
private var currentWorkId: UUID?
|
||||
@@ -93,6 +97,7 @@ final class AppState {
|
||||
isCancelling = false
|
||||
processingProgress = nil
|
||||
processingError = nil
|
||||
currentExportParams = exportParams
|
||||
|
||||
let workId = UUID()
|
||||
currentWorkId = workId
|
||||
@@ -123,6 +128,16 @@ final class AppState {
|
||||
state.isProcessing = false
|
||||
state.currentWorkId = nil
|
||||
state.currentProcessingTask = nil
|
||||
|
||||
// 保存到最近作品列表
|
||||
if let params = state.currentExportParams {
|
||||
RecentWorksManager.shared.addWork(
|
||||
assetId: result.savedAssetId,
|
||||
aspectRatio: params.aspectRatio.rawValue,
|
||||
compatibilityMode: params.compatibilityMode
|
||||
)
|
||||
}
|
||||
state.currentExportParams = nil
|
||||
}
|
||||
Analytics.shared.log(.buildLivePhotoSuccess)
|
||||
Analytics.shared.log(.saveAlbumSuccess, parameters: ["assetId": result.savedAssetId])
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 213 KiB |
@@ -10,26 +10,33 @@ import LivePhotoCore
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
NavigationStack(path: $appState.navigationPath) {
|
||||
HomeView()
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
switch route {
|
||||
case .home:
|
||||
HomeView()
|
||||
case .editor(let videoURL):
|
||||
EditorView(videoURL: videoURL)
|
||||
case .processing(let videoURL, let exportParams):
|
||||
ProcessingView(videoURL: videoURL, exportParams: exportParams)
|
||||
case .result(let workflowResult):
|
||||
ResultView(workflowResult: workflowResult)
|
||||
case .wallpaperGuide(let assetId):
|
||||
WallpaperGuideView(assetId: assetId)
|
||||
|
||||
if !hasCompletedOnboarding {
|
||||
OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
||||
} else {
|
||||
NavigationStack(path: $appState.navigationPath) {
|
||||
HomeView()
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
switch route {
|
||||
case .home:
|
||||
HomeView()
|
||||
case .editor(let videoURL):
|
||||
EditorView(videoURL: videoURL)
|
||||
case .processing(let videoURL, let exportParams):
|
||||
ProcessingView(videoURL: videoURL, exportParams: exportParams)
|
||||
case .result(let workflowResult):
|
||||
ResultView(workflowResult: workflowResult)
|
||||
case .wallpaperGuide(let assetId):
|
||||
WallpaperGuideView(assetId: assetId)
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
566
to-live-photo/to-live-photo/DesignSystem.swift
Normal file
566
to-live-photo/to-live-photo/DesignSystem.swift
Normal file
@@ -0,0 +1,566 @@
|
||||
//
|
||||
// DesignSystem.swift
|
||||
// to-live-photo
|
||||
//
|
||||
// Soft UI 设计系统 - 温暖、友好、触感化的视觉语言
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - 设计令牌
|
||||
enum DesignTokens {
|
||||
// MARK: 间距系统
|
||||
enum Spacing {
|
||||
static let xs: CGFloat = 4
|
||||
static let sm: CGFloat = 8
|
||||
static let md: CGFloat = 12
|
||||
static let lg: CGFloat = 16
|
||||
static let xl: CGFloat = 20
|
||||
static let xxl: CGFloat = 24
|
||||
static let xxxl: CGFloat = 32
|
||||
}
|
||||
|
||||
// MARK: 圆角系统
|
||||
enum Radius {
|
||||
static let sm: CGFloat = 8
|
||||
static let md: CGFloat = 12
|
||||
static let lg: CGFloat = 16
|
||||
static let xl: CGFloat = 24
|
||||
static let xxl: CGFloat = 32
|
||||
static let full: CGFloat = 9999
|
||||
}
|
||||
|
||||
// MARK: 字体大小
|
||||
enum FontSize {
|
||||
static let xs: CGFloat = 11
|
||||
static let sm: CGFloat = 13
|
||||
static let base: CGFloat = 15
|
||||
static let lg: CGFloat = 17
|
||||
static let xl: CGFloat = 20
|
||||
static let xxl: CGFloat = 24
|
||||
static let xxxl: CGFloat = 32
|
||||
static let display: CGFloat = 40
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 颜色系统
|
||||
extension Color {
|
||||
// MARK: 背景色
|
||||
static let bgPrimary = Color("bgPrimary", bundle: nil)
|
||||
static let bgSecondary = Color("bgSecondary", bundle: nil)
|
||||
static let bgElevated = Color("bgElevated", bundle: nil)
|
||||
|
||||
// MARK: 语义化背景(使用系统色自动适配深色模式)
|
||||
static let softBackground = Color(light: Color(hex: "#F0F0F3"), dark: Color(hex: "#1A1A2E"))
|
||||
static let softCard = Color(light: Color(hex: "#FFFFFF"), dark: Color(hex: "#25253B"))
|
||||
static let softElevated = Color(light: Color(hex: "#F7F7FA"), dark: Color(hex: "#2D2D47"))
|
||||
static let softPressed = Color(light: Color(hex: "#E4E4E9"), dark: Color(hex: "#16162A"))
|
||||
|
||||
// MARK: 文字色
|
||||
static let textPrimary = Color(light: Color(hex: "#2D2D3A"), dark: Color(hex: "#E4E4EB"))
|
||||
static let textSecondary = Color(light: Color(hex: "#6B6B7B"), dark: Color(hex: "#A0A0B2"))
|
||||
static let textMuted = Color(light: Color(hex: "#9999A9"), dark: Color(hex: "#6B6B7B"))
|
||||
|
||||
// MARK: 强调色
|
||||
static let accentPurple = Color(hex: "#6366F1")
|
||||
static let accentPurpleLight = Color(hex: "#818CF8")
|
||||
static let accentGreen = Color(hex: "#10B981")
|
||||
static let accentOrange = Color(hex: "#F59E0B")
|
||||
static let accentPink = Color(hex: "#EC4899")
|
||||
static let accentCyan = Color(hex: "#06B6D4")
|
||||
|
||||
// MARK: 渐变定义
|
||||
static let gradientPrimary = LinearGradient(
|
||||
colors: [Color(hex: "#6366F1"), Color(hex: "#8B5CF6")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
static let gradientSuccess = LinearGradient(
|
||||
colors: [Color(hex: "#10B981"), Color(hex: "#14B8A6")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
static let gradientWarm = LinearGradient(
|
||||
colors: [Color(hex: "#F59E0B"), Color(hex: "#F97316")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
static let gradientPink = LinearGradient(
|
||||
colors: [Color(hex: "#EC4899"), Color(hex: "#F472B6")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
static let gradientCyan = LinearGradient(
|
||||
colors: [Color(hex: "#06B6D4"), Color(hex: "#22D3EE")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 颜色扩展
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 3:
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6:
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8:
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (255, 0, 0, 0)
|
||||
}
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
|
||||
init(light: Color, dark: Color) {
|
||||
self.init(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 阴影
|
||||
struct SoftShadow: ViewModifier {
|
||||
let isPressed: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.shadow(
|
||||
color: Color.black.opacity(isPressed ? 0.15 : 0.08),
|
||||
radius: isPressed ? 4 : 8,
|
||||
x: isPressed ? 2 : 4,
|
||||
y: isPressed ? 2 : 4
|
||||
)
|
||||
.shadow(
|
||||
color: Color.white.opacity(isPressed ? 0.5 : 0.7),
|
||||
radius: isPressed ? 4 : 8,
|
||||
x: isPressed ? -2 : -4,
|
||||
y: isPressed ? -2 : -4
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SoftInnerShadow: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
|
||||
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
||||
.blur(radius: 2)
|
||||
.offset(x: 1, y: 1)
|
||||
.mask(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg).fill(LinearGradient(colors: [.black, .clear], startPoint: .topLeading, endPoint: .bottomTrailing)))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
|
||||
.stroke(Color.white.opacity(0.5), lineWidth: 1)
|
||||
.blur(radius: 2)
|
||||
.offset(x: -1, y: -1)
|
||||
.mask(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg).fill(LinearGradient(colors: [.clear, .black], startPoint: .topLeading, endPoint: .bottomTrailing)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func softShadow(isPressed: Bool = false) -> some View {
|
||||
modifier(SoftShadow(isPressed: isPressed))
|
||||
}
|
||||
|
||||
func softInnerShadow() -> some View {
|
||||
modifier(SoftInnerShadow())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 卡片
|
||||
struct SoftCard<Content: View>: View {
|
||||
let content: Content
|
||||
var padding: CGFloat = DesignTokens.Spacing.xl
|
||||
|
||||
init(padding: CGFloat = DesignTokens.Spacing.xl, @ViewBuilder content: () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(Color.softCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.xl))
|
||||
.softShadow()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 按钮样式
|
||||
struct SoftButtonStyle: ButtonStyle {
|
||||
enum Variant {
|
||||
case primary
|
||||
case secondary
|
||||
case ghost
|
||||
}
|
||||
|
||||
let variant: Variant
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: configuration.isPressed)
|
||||
.background(backgroundView(isPressed: configuration.isPressed))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func backgroundView(isPressed: Bool) -> some View {
|
||||
switch variant {
|
||||
case .primary:
|
||||
EmptyView()
|
||||
case .secondary:
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
|
||||
.fill(Color.softElevated)
|
||||
.softShadow(isPressed: isPressed)
|
||||
case .ghost:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 主按钮
|
||||
struct SoftPrimaryButton: View {
|
||||
let title: String
|
||||
let icon: String?
|
||||
let gradient: LinearGradient
|
||||
let action: () -> Void
|
||||
|
||||
@State private var isPressed = false
|
||||
|
||||
init(
|
||||
_ title: String,
|
||||
icon: String? = nil,
|
||||
gradient: LinearGradient = Color.gradientPrimary,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.gradient = gradient
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
Text(title)
|
||||
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
.background(gradient)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
|
||||
.shadow(color: Color.accentPurple.opacity(0.4), radius: 12, x: 0, y: 6)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 次级按钮
|
||||
struct SoftSecondaryButton: View {
|
||||
let title: String
|
||||
let icon: String?
|
||||
let action: () -> Void
|
||||
|
||||
init(_ title: String, icon: String? = nil, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
Text(title)
|
||||
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
|
||||
.softShadow()
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 图标按钮
|
||||
struct SoftIconButton: View {
|
||||
let icon: String
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
|
||||
init(_ icon: String, isActive: Bool = false, action: @escaping () -> Void) {
|
||||
self.icon = icon
|
||||
self.isActive = isActive
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(isActive ? .white : .textSecondary)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(
|
||||
Group {
|
||||
if isActive {
|
||||
Color.gradientPrimary
|
||||
} else {
|
||||
Color.softElevated
|
||||
}
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
.shadow(
|
||||
color: isActive ? Color.accentPurple.opacity(0.4) : Color.black.opacity(0.08),
|
||||
radius: isActive ? 8 : 4,
|
||||
x: 0,
|
||||
y: isActive ? 4 : 2
|
||||
)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 进度环
|
||||
struct SoftProgressRing: View {
|
||||
let progress: Double
|
||||
let size: CGFloat
|
||||
let lineWidth: CGFloat
|
||||
let gradient: LinearGradient
|
||||
|
||||
init(
|
||||
progress: Double,
|
||||
size: CGFloat = 120,
|
||||
lineWidth: CGFloat = 8,
|
||||
gradient: LinearGradient = Color.gradientPrimary
|
||||
) {
|
||||
self.progress = progress
|
||||
self.size = size
|
||||
self.lineWidth = lineWidth
|
||||
self.gradient = gradient
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 外部凸起背景
|
||||
Circle()
|
||||
.fill(Color.softElevated)
|
||||
.frame(width: size, height: size)
|
||||
.softShadow()
|
||||
|
||||
// 内部凹陷
|
||||
Circle()
|
||||
.fill(Color.softBackground)
|
||||
.frame(width: size - lineWidth * 2 - 16, height: size - lineWidth * 2 - 16)
|
||||
.softInnerShadow()
|
||||
|
||||
// 进度轨道
|
||||
Circle()
|
||||
.stroke(Color.softPressed, lineWidth: lineWidth)
|
||||
.frame(width: size - 16, height: size - 16)
|
||||
|
||||
// 进度指示
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.frame(width: size - 16, height: size - 16)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 标签
|
||||
struct SoftBadge: View {
|
||||
let text: String
|
||||
let color: Color
|
||||
let gradient: LinearGradient?
|
||||
|
||||
init(_ text: String, color: Color = .accentPurple, gradient: LinearGradient? = nil) {
|
||||
self.text = text
|
||||
self.color = color
|
||||
self.gradient = gradient
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: DesignTokens.FontSize.xs, weight: .semibold))
|
||||
.foregroundColor(gradient != nil ? .white : color)
|
||||
.padding(.horizontal, DesignTokens.Spacing.sm)
|
||||
.padding(.vertical, DesignTokens.Spacing.xs)
|
||||
.background(
|
||||
Group {
|
||||
if let gradient {
|
||||
gradient
|
||||
} else {
|
||||
color.opacity(0.15)
|
||||
}
|
||||
}
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 分段选择器
|
||||
struct SoftSegmentedPicker<T: Hashable>: View {
|
||||
let options: [T]
|
||||
@Binding var selection: T
|
||||
let label: (T) -> String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
selection = option
|
||||
}
|
||||
} label: {
|
||||
Text(label(option))
|
||||
.font(.system(size: DesignTokens.FontSize.sm, weight: selection == option ? .semibold : .medium))
|
||||
.foregroundColor(selection == option ? .white : .textSecondary)
|
||||
.padding(.horizontal, DesignTokens.Spacing.lg)
|
||||
.padding(.vertical, DesignTokens.Spacing.sm)
|
||||
.background(
|
||||
Group {
|
||||
if selection == option {
|
||||
Color.gradientPrimary
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(DesignTokens.Spacing.xs)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(Capsule())
|
||||
.softShadow()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Soft UI 滑块
|
||||
struct SoftSlider: View {
|
||||
@Binding var value: Double
|
||||
let range: ClosedRange<Double>
|
||||
let gradient: LinearGradient
|
||||
|
||||
init(
|
||||
value: Binding<Double>,
|
||||
in range: ClosedRange<Double>,
|
||||
gradient: LinearGradient = Color.gradientPrimary
|
||||
) {
|
||||
self._value = value
|
||||
self.range = range
|
||||
self.gradient = gradient
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let progress = (value - range.lowerBound) / (range.upperBound - range.lowerBound)
|
||||
let thumbX = width * progress
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
// 轨道背景
|
||||
Capsule()
|
||||
.fill(Color.softPressed)
|
||||
.frame(height: 8)
|
||||
.softInnerShadow()
|
||||
|
||||
// 进度填充
|
||||
Capsule()
|
||||
.fill(gradient)
|
||||
.frame(width: max(0, thumbX), height: 8)
|
||||
|
||||
// 滑块
|
||||
Circle()
|
||||
.fill(Color.softCard)
|
||||
.frame(width: 28, height: 28)
|
||||
.softShadow()
|
||||
.offset(x: max(0, min(thumbX - 14, width - 28)))
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { gesture in
|
||||
let newProgress = gesture.location.x / width
|
||||
let clampedProgress = max(0, min(1, newProgress))
|
||||
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: 28)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
#Preview("Design System") {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SoftCard {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Soft UI Design System")
|
||||
.font(.system(size: DesignTokens.FontSize.xl, weight: .bold))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Text("温暖、友好、触感化的视觉语言")
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
SoftCard {
|
||||
VStack(spacing: 16) {
|
||||
SoftProgressRing(progress: 0.72, size: 100)
|
||||
|
||||
Text("72%")
|
||||
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
|
||||
.foregroundColor(.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
SoftPrimaryButton("生成 Live Photo", icon: "wand.and.stars") {}
|
||||
|
||||
SoftSecondaryButton("继续制作", icon: "arrow.right") {}
|
||||
|
||||
HStack {
|
||||
SoftBadge("NEW", gradient: Color.gradientPrimary)
|
||||
SoftBadge("AI 增强", color: .accentPurple)
|
||||
SoftBadge("成功", color: .accentGreen)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color.softBackground)
|
||||
}
|
||||
155
to-live-photo/to-live-photo/RecentWorksManager.swift
Normal file
155
to-live-photo/to-live-photo/RecentWorksManager.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// RecentWorksManager.swift
|
||||
// to-live-photo
|
||||
//
|
||||
// 最近作品管理器:持久化存储生成记录
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
/// 最近作品记录(轻量级,不存储媒体文件)
|
||||
struct RecentWork: Codable, Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let createdAt: Date
|
||||
let assetLocalIdentifier: String // PHAsset 的 localIdentifier
|
||||
let aspectRatioRaw: String // AspectRatioTemplate.rawValue
|
||||
let compatibilityMode: Bool
|
||||
|
||||
var aspectRatioDisplayName: String {
|
||||
switch aspectRatioRaw {
|
||||
case "original": return "原比例"
|
||||
case "lock_screen": return "锁屏"
|
||||
case "full_screen": return "全屏"
|
||||
case "classic": return "4:3"
|
||||
case "square": return "1:1"
|
||||
default: return aspectRatioRaw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近作品管理器
|
||||
@MainActor
|
||||
final class RecentWorksManager: ObservableObject {
|
||||
static let shared = RecentWorksManager()
|
||||
|
||||
@Published private(set) var recentWorks: [RecentWork] = []
|
||||
|
||||
private let maxCount = 20 // 最多保存 20 条记录
|
||||
private let userDefaultsKey = "recent_works_v1"
|
||||
|
||||
private init() {
|
||||
loadFromStorage()
|
||||
}
|
||||
|
||||
/// 添加新作品记录
|
||||
func addWork(assetId: String, aspectRatio: String, compatibilityMode: Bool) {
|
||||
let work = RecentWork(
|
||||
id: UUID(),
|
||||
createdAt: Date(),
|
||||
assetLocalIdentifier: assetId,
|
||||
aspectRatioRaw: aspectRatio,
|
||||
compatibilityMode: compatibilityMode
|
||||
)
|
||||
|
||||
// 移除相同 assetId 的旧记录(避免重复)
|
||||
recentWorks.removeAll { $0.assetLocalIdentifier == assetId }
|
||||
|
||||
// 插入到最前面
|
||||
recentWorks.insert(work, at: 0)
|
||||
|
||||
// 限制数量
|
||||
if recentWorks.count > maxCount {
|
||||
recentWorks = Array(recentWorks.prefix(maxCount))
|
||||
}
|
||||
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
/// 删除作品记录
|
||||
func removeWork(_ work: RecentWork) {
|
||||
recentWorks.removeAll { $0.id == work.id }
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
/// 清空所有记录
|
||||
func clearAll() {
|
||||
recentWorks.removeAll()
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
/// 清理已删除的相册资产
|
||||
func cleanupDeletedAssets() {
|
||||
let identifiers = recentWorks.map { $0.assetLocalIdentifier }
|
||||
guard !identifiers.isEmpty else { return }
|
||||
|
||||
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
||||
var existingIds = Set<String>()
|
||||
fetchResult.enumerateObjects { asset, _, _ in
|
||||
existingIds.insert(asset.localIdentifier)
|
||||
}
|
||||
|
||||
let originalCount = recentWorks.count
|
||||
recentWorks.removeAll { !existingIds.contains($0.assetLocalIdentifier) }
|
||||
|
||||
if recentWorks.count != originalCount {
|
||||
saveToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 持久化
|
||||
|
||||
private func loadFromStorage() {
|
||||
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
recentWorks = try JSONDecoder().decode([RecentWork].self, from: data)
|
||||
} catch {
|
||||
print("[RecentWorksManager] Failed to decode: \(error)")
|
||||
recentWorks = []
|
||||
}
|
||||
}
|
||||
|
||||
private func saveToStorage() {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(recentWorks)
|
||||
UserDefaults.standard.set(data, forKey: userDefaultsKey)
|
||||
} catch {
|
||||
print("[RecentWorksManager] Failed to encode: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 PHAsset 加载缩略图
|
||||
@MainActor
|
||||
final class ThumbnailLoader: ObservableObject {
|
||||
@Published var thumbnail: UIImage?
|
||||
|
||||
func load(assetId: String, targetSize: CGSize = CGSize(width: 200, height: 300)) {
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
||||
guard let asset = result.firstObject else {
|
||||
thumbnail = nil
|
||||
return
|
||||
}
|
||||
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .opportunistic
|
||||
options.isNetworkAccessAllowed = true
|
||||
options.resizeMode = .fast
|
||||
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset,
|
||||
targetSize: targetSize,
|
||||
contentMode: .aspectFill,
|
||||
options: options
|
||||
) { [weak self] image, _ in
|
||||
Task { @MainActor in
|
||||
self?.thumbnail = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import LivePhotoCore
|
||||
|
||||
struct EditorView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
let videoURL: URL
|
||||
|
||||
@@ -31,29 +32,27 @@ struct EditorView: View {
|
||||
@State private var cropOffset: CGSize = .zero // 拖拽偏移
|
||||
@State private var cropScale: CGFloat = 1.0 // 缩放比例
|
||||
|
||||
// 兼容模式
|
||||
@State private var compatibilityMode: Bool = false
|
||||
|
||||
// AI 超分辨率
|
||||
@State private var aiEnhanceEnabled: Bool = false
|
||||
|
||||
// 视频诊断
|
||||
@State private var videoDiagnosis: VideoDiagnosis?
|
||||
|
||||
/// 是否使用 iPad 分栏布局(regular 宽度 + 横屏)
|
||||
private var useIPadLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// 视频预览区域(带裁剪框)
|
||||
cropPreviewSection
|
||||
|
||||
// 比例模板选择
|
||||
aspectRatioSection
|
||||
|
||||
// 封面帧预览
|
||||
coverFrameSection
|
||||
|
||||
// 时长控制
|
||||
durationSection
|
||||
|
||||
// 封面帧时间选择
|
||||
keyFrameSection
|
||||
|
||||
// 生成按钮
|
||||
generateButton
|
||||
Group {
|
||||
if useIPadLayout {
|
||||
iPadLayout
|
||||
} else {
|
||||
iPhoneLayout
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.navigationTitle("编辑")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -65,6 +64,106 @@ struct EditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone 布局(单列滚动)
|
||||
@ViewBuilder
|
||||
private var iPhoneLayout: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
cropPreviewSection
|
||||
|
||||
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
|
||||
diagnosisSection(diagnosis: diagnosis)
|
||||
}
|
||||
|
||||
aspectRatioSection
|
||||
coverFrameSection
|
||||
durationSection
|
||||
keyFrameSection
|
||||
aiEnhanceSection
|
||||
compatibilitySection
|
||||
generateButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad 布局(左右分栏)
|
||||
@ViewBuilder
|
||||
private var iPadLayout: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
// 左侧:视频预览
|
||||
VStack(spacing: 16) {
|
||||
iPadCropPreviewSection
|
||||
|
||||
if let diagnosis = videoDiagnosis, !diagnosis.suggestions.isEmpty {
|
||||
diagnosisSection(diagnosis: diagnosis)
|
||||
}
|
||||
|
||||
aspectRatioSection
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// 右侧:参数控制
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
coverFrameSection
|
||||
durationSection
|
||||
keyFrameSection
|
||||
aiEnhanceSection
|
||||
compatibilitySection
|
||||
generateButton
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
// MARK: - iPad 裁剪预览(更大尺寸)
|
||||
@ViewBuilder
|
||||
private var iPadCropPreviewSection: some View {
|
||||
GeometryReader { geometry in
|
||||
let containerWidth = geometry.size.width
|
||||
let containerHeight = min(500, geometry.size.width * 1.2)
|
||||
|
||||
ZStack {
|
||||
if let player {
|
||||
VideoPlayer(player: player)
|
||||
.aspectRatio(videoNaturalSize, contentMode: .fit)
|
||||
.scaleEffect(cropScale)
|
||||
.offset(cropOffset)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
cropScale = max(1.0, min(3.0, value))
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
cropOffset = value.translation
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
|
||||
if selectedAspectRatio != .original {
|
||||
CropOverlay(
|
||||
aspectRatio: selectedAspectRatio,
|
||||
containerSize: CGSize(width: containerWidth, height: containerHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: containerWidth, height: containerHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.background(Color.black.clipShape(RoundedRectangle(cornerRadius: 16)))
|
||||
}
|
||||
.frame(height: 500)
|
||||
}
|
||||
|
||||
// MARK: - 裁剪预览
|
||||
@ViewBuilder
|
||||
private var cropPreviewSection: some View {
|
||||
@@ -253,6 +352,178 @@ struct EditorView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - AI 超分辨率开关
|
||||
@ViewBuilder
|
||||
private var aiEnhanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle(isOn: $aiEnhanceEnabled) {
|
||||
HStack {
|
||||
Image(systemName: "wand.and.stars.inverse")
|
||||
.foregroundStyle(.purple)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("AI 超分辨率")
|
||||
.font(.headline)
|
||||
Text("使用 AI 提升封面画质")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.purple)
|
||||
.disabled(!AIEnhancer.isAvailable())
|
||||
|
||||
if aiEnhanceEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.purple)
|
||||
.font(.caption)
|
||||
Text("分辨率提升约 2 倍")
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(.purple)
|
||||
.font(.caption)
|
||||
Text("处理时间:约 2-3 秒")
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "cpu")
|
||||
.foregroundStyle(.purple)
|
||||
.font(.caption)
|
||||
Text("本地 AI 处理,无需网络")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
if !AIEnhancer.isAvailable() {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.yellow)
|
||||
.font(.caption)
|
||||
Text("当前设备不支持 AI 增强")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.purple.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - 兼容模式开关
|
||||
@ViewBuilder
|
||||
private var compatibilitySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle(isOn: $compatibilityMode) {
|
||||
HStack {
|
||||
Image(systemName: "gearshape.2")
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("兼容模式")
|
||||
.font(.headline)
|
||||
Text("适用于较旧设备或生成失败时")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.accentColor)
|
||||
|
||||
if compatibilityMode {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
Text("分辨率:720p")
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
Text("帧率:30fps")
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
Text("编码:H.264")
|
||||
.font(.caption)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
Text("色彩:SDR")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - 诊断建议
|
||||
@ViewBuilder
|
||||
private func diagnosisSection(diagnosis: VideoDiagnosis) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text("视频检测")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
ForEach(diagnosis.suggestions.indices, id: \.self) { index in
|
||||
let suggestion = diagnosis.suggestions[index]
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: suggestion.icon)
|
||||
.foregroundStyle(suggestion.iconColor)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(suggestion.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text(suggestion.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let actionText = suggestion.actionText {
|
||||
Button {
|
||||
withAnimation {
|
||||
compatibilityMode = true
|
||||
}
|
||||
} label: {
|
||||
Text(actionText)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.yellow.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - 生成按钮
|
||||
@ViewBuilder
|
||||
private var generateButton: some View {
|
||||
@@ -270,6 +541,7 @@ struct EditorView: View {
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
@@ -281,16 +553,49 @@ struct EditorView: View {
|
||||
let durationCMTime = try await asset.load(.duration)
|
||||
let durationSeconds = durationCMTime.seconds
|
||||
|
||||
// 获取视频自然尺寸
|
||||
var diagnosis = VideoDiagnosis()
|
||||
diagnosis.duration = durationSeconds
|
||||
|
||||
// 获取视频自然尺寸和诊断信息
|
||||
if let videoTrack = try await asset.loadTracks(withMediaType: .video).first {
|
||||
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||
let transform = try await videoTrack.load(.preferredTransform)
|
||||
let transformedSize = naturalSize.applying(transform)
|
||||
let absSize = CGSize(
|
||||
width: abs(transformedSize.width),
|
||||
height: abs(transformedSize.height)
|
||||
)
|
||||
|
||||
// 检测高分辨率(超过 4K)
|
||||
let maxDim = max(absSize.width, absSize.height)
|
||||
diagnosis.isHighRes = maxDim > 3840
|
||||
|
||||
// 检测高帧率
|
||||
let frameRate = try await videoTrack.load(.nominalFrameRate)
|
||||
diagnosis.isHighFrameRate = frameRate > 60
|
||||
|
||||
// 检测编码格式
|
||||
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
|
||||
if let formatDesc = formatDescriptions.first {
|
||||
let mediaSubType = CMFormatDescriptionGetMediaSubType(formatDesc)
|
||||
// kCMVideoCodecType_HEVC = 'hvc1'
|
||||
diagnosis.isHEVC = mediaSubType == kCMVideoCodecType_HEVC
|
||||
|
||||
// 检测 HDR(通过 colorPrimaries 或 transferFunction)
|
||||
if let extensions = CMFormatDescriptionGetExtensions(formatDesc) as? [String: Any] {
|
||||
let colorPrimaries = extensions[kCMFormatDescriptionExtension_ColorPrimaries as String] as? String
|
||||
let transferFunction = extensions[kCMFormatDescriptionExtension_TransferFunction as String] as? String
|
||||
|
||||
// HDR 视频通常使用 BT.2020 色域和 PQ/HLG 传输函数
|
||||
let isHDRColorSpace = colorPrimaries == (kCMFormatDescriptionColorPrimaries_ITU_R_2020 as String)
|
||||
let isHDRTransfer = transferFunction == (kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String) ||
|
||||
transferFunction == (kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String)
|
||||
diagnosis.isHDR = isHDRColorSpace || isHDRTransfer
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
videoNaturalSize = CGSize(
|
||||
width: abs(transformedSize.width),
|
||||
height: abs(transformedSize.height)
|
||||
)
|
||||
videoNaturalSize = absSize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +605,7 @@ struct EditorView: View {
|
||||
keyFrameTime = trimEnd / 2
|
||||
player = AVPlayer(url: videoURL)
|
||||
player?.play()
|
||||
videoDiagnosis = diagnosis
|
||||
extractCoverFrame()
|
||||
}
|
||||
} catch {
|
||||
@@ -380,18 +686,27 @@ struct EditorView: View {
|
||||
"trimStart": trimStart,
|
||||
"trimEnd": trimEnd,
|
||||
"keyFrameTime": keyFrameTime,
|
||||
"aspectRatio": selectedAspectRatio.rawValue
|
||||
"aspectRatio": selectedAspectRatio.rawValue,
|
||||
"compatibilityMode": compatibilityMode,
|
||||
"aiEnhanceEnabled": aiEnhanceEnabled
|
||||
])
|
||||
|
||||
let cropRect = calculateCropRect()
|
||||
|
||||
let params = ExportParams(
|
||||
var params = ExportParams(
|
||||
trimStart: trimStart,
|
||||
trimEnd: trimEnd,
|
||||
keyFrameTime: keyFrameTime,
|
||||
cropRect: cropRect,
|
||||
aspectRatio: selectedAspectRatio
|
||||
aspectRatio: selectedAspectRatio,
|
||||
aiEnhanceConfig: aiEnhanceEnabled ? .standard : .disabled
|
||||
)
|
||||
|
||||
// 应用兼容模式
|
||||
if compatibilityMode {
|
||||
params = params.withCompatibilityMode()
|
||||
}
|
||||
|
||||
appState.navigateTo(.processing(videoURL: videoURL, exportParams: params))
|
||||
}
|
||||
}
|
||||
@@ -497,6 +812,73 @@ struct CropOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 视频诊断
|
||||
struct VideoDiagnosis {
|
||||
var isHEVC: Bool = false
|
||||
var isHDR: Bool = false
|
||||
var isHighRes: Bool = false // 超过 4K
|
||||
var isHighFrameRate: Bool = false // 超过 60fps
|
||||
var duration: Double = 0
|
||||
|
||||
var suggestions: [DiagnosisSuggestion] {
|
||||
var result: [DiagnosisSuggestion] = []
|
||||
|
||||
if isHDR {
|
||||
result.append(DiagnosisSuggestion(
|
||||
icon: "sun.max.fill",
|
||||
iconColor: .orange,
|
||||
title: "HDR 视频",
|
||||
description: "将自动转换为 SDR 以确保兼容性",
|
||||
actionText: nil,
|
||||
action: nil
|
||||
))
|
||||
}
|
||||
|
||||
if isHighRes {
|
||||
result.append(DiagnosisSuggestion(
|
||||
icon: "4k.tv.fill",
|
||||
iconColor: .purple,
|
||||
title: "高分辨率视频",
|
||||
description: "建议开启兼容模式以加快处理速度",
|
||||
actionText: "开启兼容模式",
|
||||
action: nil
|
||||
))
|
||||
}
|
||||
|
||||
if isHighFrameRate {
|
||||
result.append(DiagnosisSuggestion(
|
||||
icon: "speedometer",
|
||||
iconColor: .blue,
|
||||
title: "高帧率视频",
|
||||
description: "将自动转换为 60fps",
|
||||
actionText: nil,
|
||||
action: nil
|
||||
))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
struct DiagnosisSuggestion {
|
||||
var icon: String
|
||||
var iconColor: Color
|
||||
var title: String
|
||||
var description: String
|
||||
var actionText: String?
|
||||
var action: (() -> Void)?
|
||||
}
|
||||
|
||||
// MARK: - 缩放按钮样式
|
||||
struct ScaleButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
EditorView(videoURL: URL(fileURLWithPath: "/tmp/test.mov"))
|
||||
|
||||
@@ -2,94 +2,233 @@
|
||||
// HomeView.swift
|
||||
// to-live-photo
|
||||
//
|
||||
// 首页:选择视频入口
|
||||
// 首页:选择视频入口 + 最近作品列表
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import AVKit
|
||||
import Photos
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@StateObject private var recentWorks = RecentWorksManager.shared
|
||||
@State private var selectedItem: PhotosPickerItem?
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "livephoto")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
Text("Live Photo 制作")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("选择一段视频,将其转换为 Live Photo\n然后设置为动态锁屏壁纸")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
|
||||
PhotosPicker(
|
||||
selection: $selectedItem,
|
||||
matching: .videos,
|
||||
photoLibrary: .shared()
|
||||
) {
|
||||
HStack {
|
||||
Image(systemName: "video.badge.plus")
|
||||
Text("选择视频")
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: DesignTokens.Spacing.xxl) {
|
||||
// 顶部导入区域
|
||||
heroSection
|
||||
|
||||
// 最近作品或提示
|
||||
if !recentWorks.recentWorks.isEmpty {
|
||||
recentWorksSection
|
||||
} else {
|
||||
emptyStateHint
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xl)
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
}
|
||||
.background(Color.softBackground.ignoresSafeArea())
|
||||
.navigationTitle("Live Photo")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
SoftIconButton("gearshape") {
|
||||
appState.navigateTo(.settings)
|
||||
}
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(isLoading)
|
||||
.onChange(of: selectedItem) { _, _ in
|
||||
Analytics.shared.log(.homeImportVideoClick)
|
||||
}
|
||||
|
||||
if isLoading {
|
||||
ProgressView("正在加载视频...")
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.navigationTitle("首页")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: selectedItem) { _, newValue in
|
||||
Task {
|
||||
await handleSelectedItem(newValue)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
recentWorks.cleanupDeletedAssets()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Hero 区域
|
||||
@ViewBuilder
|
||||
private var heroSection: some View {
|
||||
SoftCard(padding: DesignTokens.Spacing.xxl) {
|
||||
VStack(spacing: DesignTokens.Spacing.xl) {
|
||||
// 动态图标
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.gradientPink)
|
||||
.frame(width: 88, height: 88)
|
||||
.shadow(color: Color.accentPink.opacity(0.4), radius: 16, x: 0, y: 8)
|
||||
|
||||
Image(systemName: "livephoto")
|
||||
.font(.system(size: 40, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
VStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Text("Live Photo 制作")
|
||||
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Text("选择视频,一键转换为动态壁纸")
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// 导入按钮
|
||||
PhotosPicker(
|
||||
selection: $selectedItem,
|
||||
matching: .videos,
|
||||
photoLibrary: .shared()
|
||||
) {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Image(systemName: "video.badge.plus")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text("选择视频")
|
||||
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
.background(Color.gradientPrimary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
|
||||
.shadow(color: Color.accentPurple.opacity(0.4), radius: 12, x: 0, y: 6)
|
||||
}
|
||||
.buttonStyle(HomeButtonStyle())
|
||||
.disabled(isLoading)
|
||||
.onChange(of: selectedItem) { _, _ in
|
||||
Analytics.shared.log(.homeImportVideoClick)
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
if isLoading {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(.accentPurple)
|
||||
Text("正在加载视频...")
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
if let errorMessage {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.accentOrange)
|
||||
Text(errorMessage)
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.foregroundColor(.accentOrange)
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 空状态提示
|
||||
@ViewBuilder
|
||||
private var emptyStateHint: some View {
|
||||
SoftCard(padding: DesignTokens.Spacing.xl) {
|
||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||
HStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.accentOrange.opacity(0.15))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.accentOrange)
|
||||
}
|
||||
|
||||
Text("快速上手")
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
|
||||
QuickStartStep(number: 1, text: "点击上方「选择视频」导入素材", color: .accentPurple)
|
||||
QuickStartStep(number: 2, text: "调整比例和时长,选择封面帧", color: .accentCyan)
|
||||
QuickStartStep(number: 3, text: "开启 AI 增强提升画质(可选)", color: .accentPink)
|
||||
QuickStartStep(number: 4, text: "生成后按引导设置为壁纸", color: .accentGreen)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("完成后的作品会显示在这里")
|
||||
.font(.system(size: DesignTokens.FontSize.xs))
|
||||
.foregroundColor(.textMuted)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 最近作品
|
||||
@ViewBuilder
|
||||
private var recentWorksSection: some View {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.lg) {
|
||||
HStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.accentCyan.opacity(0.15))
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.accentCyan)
|
||||
}
|
||||
|
||||
Text("最近作品")
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(recentWorks.recentWorks.count) 个")
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.foregroundColor(.textMuted)
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xs)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: DesignTokens.Spacing.lg) {
|
||||
ForEach(recentWorks.recentWorks) { work in
|
||||
RecentWorkCard(work: work) {
|
||||
appState.navigateTo(.wallpaperGuide(assetId: work.assetLocalIdentifier))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xs)
|
||||
.padding(.vertical, DesignTokens.Spacing.sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
|
||||
do {
|
||||
guard let movie = try await item.loadTransferable(type: VideoTransferable.self) else {
|
||||
errorMessage = "无法加载视频"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isLoading = false
|
||||
Analytics.shared.log(.importVideoSuccess)
|
||||
appState.navigateTo(.editor(videoURL: movie.url))
|
||||
@@ -101,9 +240,37 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 快速上手步骤
|
||||
struct QuickStartStep: View {
|
||||
let number: Int
|
||||
let text: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DesignTokens.Spacing.md) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Text("\(number)")
|
||||
.font(.system(size: DesignTokens.FontSize.xs, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.foregroundColor(.textSecondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 视频传输类型
|
||||
struct VideoTransferable: Transferable {
|
||||
let url: URL
|
||||
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(contentType: .movie) { video in
|
||||
SentTransferredFile(video.url)
|
||||
@@ -111,17 +278,132 @@ struct VideoTransferable: Transferable {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let filename = "import_\(UUID().uuidString).mov"
|
||||
let destURL = tempDir.appendingPathComponent(filename)
|
||||
|
||||
|
||||
if FileManager.default.fileExists(atPath: destURL.path) {
|
||||
try FileManager.default.removeItem(at: destURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: received.file, to: destURL)
|
||||
|
||||
|
||||
return VideoTransferable(url: destURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 最近作品卡片
|
||||
struct RecentWorkCard: View {
|
||||
let work: RecentWork
|
||||
let onTap: () -> Void
|
||||
|
||||
@StateObject private var thumbnailLoader = ThumbnailLoader()
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||
// 缩略图
|
||||
ZStack {
|
||||
if let thumbnail = thumbnailLoader.thumbnail {
|
||||
Image(uiImage: thumbnail)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 110, height: 150)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: DesignTokens.Radius.md)
|
||||
.fill(Color.softPressed)
|
||||
.frame(width: 110, height: 150)
|
||||
.overlay {
|
||||
Image(systemName: "livephoto")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.textMuted)
|
||||
}
|
||||
.shimmering()
|
||||
}
|
||||
|
||||
// Live Photo 标识
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
SoftBadge("LIVE", gradient: Color.gradientPink)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(DesignTokens.Spacing.sm)
|
||||
}
|
||||
.frame(width: 110, height: 150)
|
||||
|
||||
// 信息
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(work.aspectRatioDisplayName)
|
||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .medium))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Text(work.createdAt.formatted(.relative(presentation: .named)))
|
||||
.font(.system(size: DesignTokens.FontSize.xs))
|
||||
.foregroundColor(.textMuted)
|
||||
}
|
||||
}
|
||||
.padding(DesignTokens.Spacing.sm)
|
||||
.background(Color.softCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
|
||||
.softShadow(isPressed: isPressed)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scaleEffect(isPressed ? 0.97 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed)
|
||||
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
|
||||
isPressed = pressing
|
||||
}, perform: {})
|
||||
.onAppear {
|
||||
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 按钮样式
|
||||
struct HomeButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 骨架屏闪烁效果
|
||||
struct ShimmerModifier: ViewModifier {
|
||||
@State private var phase: CGFloat = -200
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.clear,
|
||||
Color.white.opacity(0.4),
|
||||
Color.clear
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: 100)
|
||||
.offset(x: phase)
|
||||
.mask(content)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
|
||||
phase = 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func shimmering() -> some View {
|
||||
modifier(ShimmerModifier())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
HomeView()
|
||||
|
||||
245
to-live-photo/to-live-photo/Views/OnboardingView.swift
Normal file
245
to-live-photo/to-live-photo/Views/OnboardingView.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// OnboardingView.swift
|
||||
// to-live-photo
|
||||
//
|
||||
// 首次使用引导页 - Soft UI 风格
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Binding var hasCompletedOnboarding: Bool
|
||||
@State private var currentPage = 0
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
private let pages: [OnboardingPage] = [
|
||||
OnboardingPage(
|
||||
icon: "video.fill",
|
||||
gradient: Color.gradientPrimary,
|
||||
title: "选择视频",
|
||||
description: "从相册选择你喜欢的视频片段\n支持各种格式和分辨率"
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "crop",
|
||||
gradient: Color.gradientWarm,
|
||||
title: "编辑调整",
|
||||
description: "选择比例模板、调整时长\n挑选最佳封面帧"
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "wand.and.stars",
|
||||
gradient: Color.gradientPink,
|
||||
title: "AI 增强",
|
||||
description: "开启 AI 超分辨率\n提升封面画质,让壁纸更清晰"
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "livephoto",
|
||||
gradient: Color.gradientSuccess,
|
||||
title: "生成壁纸",
|
||||
description: "一键生成 Live Photo\n按引导设置为动态锁屏壁纸"
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 背景
|
||||
Color.softBackground.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 页面内容
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(pages.indices, id: \.self) { index in
|
||||
pageView(pages[index], index: index)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: currentPage)
|
||||
|
||||
// 底部控制区
|
||||
bottomControls
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 页面内容
|
||||
@ViewBuilder
|
||||
private func pageView(_ page: OnboardingPage, index: Int) -> some View {
|
||||
VStack(spacing: DesignTokens.Spacing.xxxl) {
|
||||
Spacer()
|
||||
|
||||
// 动画图标
|
||||
ZStack {
|
||||
// 外环光晕
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color(hex: gradientStartColor(for: index)).opacity(0.3),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 120
|
||||
)
|
||||
)
|
||||
.frame(width: 240, height: 240)
|
||||
|
||||
// 主图标容器
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(page.gradient)
|
||||
.frame(width: 120, height: 120)
|
||||
.shadow(
|
||||
color: Color(hex: gradientStartColor(for: index)).opacity(0.4),
|
||||
radius: 24,
|
||||
x: 0,
|
||||
y: 12
|
||||
)
|
||||
|
||||
Image(systemName: page.icon)
|
||||
.font(.system(size: 48, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// 文字内容
|
||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||
Text(page.title)
|
||||
.font(.system(size: DesignTokens.FontSize.xxxl, weight: .bold))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
Text(page.description)
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xxxl)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 底部控制区
|
||||
@ViewBuilder
|
||||
private var bottomControls: some View {
|
||||
VStack(spacing: DesignTokens.Spacing.xl) {
|
||||
// 页面指示器
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
ForEach(pages.indices, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(index == currentPage ? Color.accentPurple : Color.softPressed)
|
||||
.frame(width: index == currentPage ? 24 : 8, height: 8)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentPage)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, DesignTokens.Spacing.md)
|
||||
|
||||
// 主按钮
|
||||
Button {
|
||||
if currentPage < pages.count - 1 {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
|
||||
currentPage += 1
|
||||
}
|
||||
} else {
|
||||
completeOnboarding()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Text(currentPage < pages.count - 1 ? "下一步" : "开始使用")
|
||||
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
|
||||
|
||||
Image(systemName: currentPage < pages.count - 1 ? "arrow.right" : "checkmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
.background(Color.gradientPrimary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
|
||||
.shadow(color: Color.accentPurple.opacity(0.4), radius: 12, x: 0, y: 6)
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
.padding(.horizontal, DesignTokens.Spacing.xxl)
|
||||
|
||||
// 跳过按钮
|
||||
if currentPage < pages.count - 1 {
|
||||
Button {
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("跳过")
|
||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .medium))
|
||||
.foregroundColor(.textMuted)
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.frame(height: DesignTokens.Spacing.xxl)
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.xl)
|
||||
.background(
|
||||
Color.softCard
|
||||
.clipShape(
|
||||
RoundedCorner(radius: DesignTokens.Radius.xxl, corners: [.topLeft, .topRight])
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 20, x: 0, y: -10)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 辅助方法
|
||||
|
||||
private func gradientStartColor(for index: Int) -> String {
|
||||
switch index {
|
||||
case 0: return "#6366F1"
|
||||
case 1: return "#F59E0B"
|
||||
case 2: return "#EC4899"
|
||||
case 3: return "#10B981"
|
||||
default: return "#6366F1"
|
||||
}
|
||||
}
|
||||
|
||||
private func completeOnboarding() {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
|
||||
hasCompletedOnboarding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 数据模型
|
||||
private struct OnboardingPage {
|
||||
let icon: String
|
||||
let gradient: LinearGradient
|
||||
let title: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
// MARK: - 按钮样式
|
||||
struct OnboardingButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 圆角辅助
|
||||
struct RoundedCorner: Shape {
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(
|
||||
roundedRect: rect,
|
||||
byRoundingCorners: corners,
|
||||
cornerRadii: CGSize(width: radius, height: radius)
|
||||
)
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingView(hasCompletedOnboarding: .constant(false))
|
||||
}
|
||||
174
to-live-photo/to-live-photo/Views/PrivacyPolicyView.swift
Normal file
174
to-live-photo/to-live-photo/Views/PrivacyPolicyView.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
//
|
||||
// PrivacyPolicyView.swift
|
||||
// to-live-photo
|
||||
//
|
||||
// 隐私政策和使用条款页面
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PrivacyPolicyView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("最后更新:2025年12月")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Group {
|
||||
sectionHeader("概述")
|
||||
Text("Live Photo Maker(以下简称\"本应用\")尊重并保护您的隐私。本隐私政策说明我们如何收集、使用和保护您的信息。")
|
||||
Text("本应用提供两种处理模式:本地处理(免费)和云端增强(付费订阅)。不同模式的数据处理方式有所不同。")
|
||||
|
||||
sectionHeader("本地处理模式(免费)")
|
||||
bulletPoint("设备端处理", "视频转换、本地 AI 增强均在您的设备上完成,数据不会离开您的手机。")
|
||||
bulletPoint("无数据上传", "使用本地处理模式时,您的图片和视频不会上传到任何服务器。")
|
||||
|
||||
sectionHeader("云端增强模式(Pro)")
|
||||
bulletPoint("数据传输", "使用云端增强时,您选择的图片或视频帧会通过加密连接上传至服务器处理。")
|
||||
bulletPoint("即时删除", "处理完成后,您的原始数据和处理结果将在 24 小时内从服务器自动删除。")
|
||||
bulletPoint("数据安全", "所有传输数据均经过端到端加密,服务器位于符合 GDPR 标准的数据中心。")
|
||||
Text("⚠️ 云端增强功能需要网络连接,每次使用前会明确提示并征得您的同意。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
sectionHeader("权限说明")
|
||||
bulletPoint("相册读取权限", "用于从相册选择视频素材。")
|
||||
bulletPoint("相册写入权限", "用于将生成的 Live Photo 保存到相册。")
|
||||
bulletPoint("网络访问(可选)", "仅在使用云端增强功能时需要。")
|
||||
|
||||
sectionHeader("数据收集")
|
||||
bulletPoint("匿名使用统计", "我们可能收集匿名的功能使用统计,用于改进应用体验。这些数据不包含任何可识别个人身份的信息。")
|
||||
bulletPoint("订阅信息", "订阅购买通过 Apple 处理,我们仅接收订阅状态,不会获取您的支付详情。")
|
||||
|
||||
sectionHeader("数据存储")
|
||||
bulletPoint("临时缓存", "处理过程中产生的临时文件存储在应用沙盒内,您可以在设置中清理。")
|
||||
bulletPoint("最近作品", "仅存储作品的缩略图和参数信息,不重复存储媒体内容。")
|
||||
|
||||
sectionHeader("第三方服务")
|
||||
bulletPoint("Apple 服务", "App Store 内购、iCloud(如适用),受 Apple 隐私政策约束。")
|
||||
bulletPoint("崩溃分析", "用于收集匿名崩溃报告以改进应用稳定性。")
|
||||
|
||||
sectionHeader("您的权利")
|
||||
bulletPoint("删除数据", "您可以随时在应用设置中清理所有本地缓存和作品记录。")
|
||||
bulletPoint("退出云端服务", "您可以随时停止使用云端增强功能,继续使用完全离线的本地处理。")
|
||||
|
||||
sectionHeader("联系我们")
|
||||
Text("如果您对本隐私政策有任何疑问,请通过以下方式联系我们:")
|
||||
Text("邮箱:support@let5see.xyz")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("隐私政策")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func bulletPoint(_ title: String, _ description: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.fontWeight(.medium)
|
||||
Text(description)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TermsOfServiceView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("最后更新:2025年12月")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Group {
|
||||
sectionHeader("接受条款")
|
||||
Text("使用 Live Photo Maker 即表示您同意本使用条款。如果您不同意这些条款,请勿使用本应用。")
|
||||
|
||||
sectionHeader("服务说明")
|
||||
Text("Live Photo Maker 是一款将视频转换为 Live Photo 的工具应用,帮助您创建可用于动态壁纸的内容。")
|
||||
Text("本应用提供免费基础功能和付费高级功能(Pro)。部分高级功能可能需要网络连接和云端处理。")
|
||||
|
||||
sectionHeader("订阅与内购")
|
||||
bulletPoint("付款方式", "所有购买通过 Apple App Store 处理。订阅费用将从您的 Apple ID 账户中扣除。")
|
||||
bulletPoint("自动续订", "订阅将在到期前 24 小时内自动续订,除非您在到期前至少 24 小时关闭自动续订。")
|
||||
bulletPoint("取消订阅", "您可以随时在 App Store 账户设置中管理或取消订阅。")
|
||||
bulletPoint("退款政策", "订阅退款需通过 Apple 申请处理。")
|
||||
|
||||
sectionHeader("使用限制")
|
||||
bulletPoint("合法使用", "您只能将本应用用于合法目的,不得用于处理侵犯他人版权或违法的内容。")
|
||||
bulletPoint("个人使用", "本应用仅供个人非商业用途,商业用途需另行授权。")
|
||||
bulletPoint("禁止滥用", "禁止恶意使用云端服务、尝试破解付费功能等行为。")
|
||||
|
||||
sectionHeader("云端服务")
|
||||
bulletPoint("网络依赖", "云端功能需要稳定的网络连接,网络问题可能导致处理失败。")
|
||||
bulletPoint("服务可用性", "我们将尽力保证服务稳定,但不保证 100% 可用性。")
|
||||
|
||||
sectionHeader("免责声明")
|
||||
bulletPoint("壁纸设置", "本应用提供 Live Photo 生成功能,壁纸设置需通过系统照片应用完成。动态效果取决于您的设备和系统版本。")
|
||||
bulletPoint("内容责任", "您对使用本应用处理的所有内容负全部责任。")
|
||||
bulletPoint("AI 处理结果", "AI 增强效果可能因原始素材质量而异,我们不保证特定的处理效果。")
|
||||
|
||||
sectionHeader("知识产权")
|
||||
Text("本应用及其所有相关内容(包括但不限于代码、设计、图标、AI 模型)的知识产权归开发者所有。")
|
||||
|
||||
sectionHeader("条款变更")
|
||||
Text("我们可能会不时更新本使用条款。继续使用本应用即表示您接受更新后的条款。")
|
||||
|
||||
sectionHeader("联系方式")
|
||||
Text("如有问题,请联系:support@let5see.xyz")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("使用条款")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func bulletPoint(_ title: String, _ description: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.fontWeight(.medium)
|
||||
Text(description)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Privacy Policy") {
|
||||
NavigationStack {
|
||||
PrivacyPolicyView()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Terms of Service") {
|
||||
NavigationStack {
|
||||
TermsOfServiceView()
|
||||
}
|
||||
}
|
||||
@@ -15,20 +15,26 @@ struct ProcessingView: View {
|
||||
let exportParams: ExportParams
|
||||
|
||||
@State private var hasStarted = false
|
||||
@State private var pulseAnimation = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
// 背景
|
||||
Color.softBackground.ignoresSafeArea()
|
||||
|
||||
if appState.processingError != nil {
|
||||
errorContent
|
||||
} else {
|
||||
progressContent
|
||||
VStack(spacing: DesignTokens.Spacing.xxxl) {
|
||||
Spacer()
|
||||
|
||||
if appState.processingError != nil {
|
||||
errorContent
|
||||
} else {
|
||||
progressContent
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.padding(.horizontal, DesignTokens.Spacing.xxl)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.navigationTitle("生成中")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(appState.isProcessing)
|
||||
@@ -39,6 +45,7 @@ struct ProcessingView: View {
|
||||
appState.cancelProcessing()
|
||||
appState.pop()
|
||||
}
|
||||
.foregroundColor(.accentPurple)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,72 +54,199 @@ struct ProcessingView: View {
|
||||
hasStarted = true
|
||||
await startProcessing()
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
|
||||
pulseAnimation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var progressContent: some View {
|
||||
if appState.isCancelling {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
// 取消中状态
|
||||
VStack(spacing: DesignTokens.Spacing.xl) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.softElevated)
|
||||
.frame(width: 120, height: 120)
|
||||
.softShadow()
|
||||
|
||||
Text("正在取消...")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(stageText)
|
||||
.font(.headline)
|
||||
|
||||
if let progress = appState.processingProgress {
|
||||
Text(String(format: "%.0f%%", progress.fraction * 100))
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.tint)
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
.tint(.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Text("正在生成 Live Photo,请稍候...")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("正在取消...")
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
} else {
|
||||
// 正常进度
|
||||
VStack(spacing: DesignTokens.Spacing.xxl) {
|
||||
// 进度环 + 动态图标
|
||||
ZStack {
|
||||
// 脉冲动画背景
|
||||
Circle()
|
||||
.fill(Color.accentPurple.opacity(0.1))
|
||||
.frame(width: pulseAnimation ? 180 : 160, height: pulseAnimation ? 180 : 160)
|
||||
.animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: pulseAnimation)
|
||||
|
||||
// 进度环
|
||||
SoftProgressRing(
|
||||
progress: appState.processingProgress?.fraction ?? 0,
|
||||
size: 140,
|
||||
lineWidth: 10,
|
||||
gradient: stageGradient
|
||||
)
|
||||
|
||||
// 动态图标
|
||||
VStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: stageIcon)
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(stageGradient)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
|
||||
if let progress = appState.processingProgress {
|
||||
Text(String(format: "%.0f%%", progress.fraction * 100))
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .bold))
|
||||
.foregroundColor(.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段信息
|
||||
VStack(spacing: DesignTokens.Spacing.md) {
|
||||
Text(stageText)
|
||||
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
|
||||
.foregroundColor(.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut(duration: 0.3), value: stageText)
|
||||
|
||||
Text(stageDescription)
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// 阶段指示器
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
ForEach(0..<7) { index in
|
||||
Circle()
|
||||
.fill(index <= currentStageIndex ? Color.accentPurple : Color.softPressed)
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(.spring(response: 0.3), value: currentStageIndex)
|
||||
}
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var errorContent: some View {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.red)
|
||||
VStack(spacing: DesignTokens.Spacing.xxl) {
|
||||
// 错误图标
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.accentOrange.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
if let error = appState.processingError {
|
||||
VStack(spacing: 8) {
|
||||
Text("生成失败")
|
||||
.font(.headline)
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.accentOrange)
|
||||
}
|
||||
|
||||
Text(error.message)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
// 错误信息
|
||||
if let error = appState.processingError {
|
||||
VStack(spacing: DesignTokens.Spacing.md) {
|
||||
Text("生成失败")
|
||||
.font(.system(size: DesignTokens.FontSize.xl, weight: .bold))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
if !error.suggestedActions.isEmpty {
|
||||
Text("建议:\(error.suggestedActions.joined(separator: "、"))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(error.message)
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if !error.suggestedActions.isEmpty {
|
||||
SoftCard(padding: DesignTokens.Spacing.lg) {
|
||||
VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.foregroundColor(.accentOrange)
|
||||
Text("建议")
|
||||
.font(.system(size: DesignTokens.FontSize.sm, weight: .semibold))
|
||||
.foregroundColor(.textPrimary)
|
||||
}
|
||||
|
||||
ForEach(error.suggestedActions, id: \.self) { action in
|
||||
HStack(spacing: DesignTokens.Spacing.sm) {
|
||||
Circle()
|
||||
.fill(Color.accentOrange)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(action)
|
||||
.font(.system(size: DesignTokens.FontSize.sm))
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
appState.pop()
|
||||
} label: {
|
||||
Text("返回重试")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
// 返回按钮
|
||||
SoftPrimaryButton("返回重试", icon: "arrow.counterclockwise", gradient: Color.gradientWarm) {
|
||||
appState.pop()
|
||||
}
|
||||
.padding(.horizontal, DesignTokens.Spacing.xxl)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 计算属性
|
||||
|
||||
private var currentStageIndex: Int {
|
||||
guard let stage = appState.processingProgress?.stage else { return 0 }
|
||||
switch stage {
|
||||
case .normalize: return 0
|
||||
case .extractKeyFrame: return 1
|
||||
case .aiEnhance: return 2
|
||||
case .writePhotoMetadata: return 3
|
||||
case .writeVideoMetadata: return 4
|
||||
case .saveToAlbum: return 5
|
||||
case .validate: return 6
|
||||
}
|
||||
}
|
||||
|
||||
private var stageIcon: String {
|
||||
guard let stage = appState.processingProgress?.stage else {
|
||||
return "hourglass"
|
||||
}
|
||||
switch stage {
|
||||
case .normalize: return "film"
|
||||
case .extractKeyFrame: return "photo"
|
||||
case .aiEnhance: return "wand.and.stars"
|
||||
case .writePhotoMetadata: return "doc.badge.gearshape"
|
||||
case .writeVideoMetadata: return "video.badge.checkmark"
|
||||
case .saveToAlbum: return "square.and.arrow.down"
|
||||
case .validate: return "checkmark.seal"
|
||||
}
|
||||
}
|
||||
|
||||
private var stageGradient: LinearGradient {
|
||||
guard let stage = appState.processingProgress?.stage else {
|
||||
return Color.gradientPrimary
|
||||
}
|
||||
switch stage {
|
||||
case .normalize: return Color.gradientPrimary
|
||||
case .extractKeyFrame: return Color.gradientCyan
|
||||
case .aiEnhance: return Color.gradientPink
|
||||
case .writePhotoMetadata: return Color.gradientPrimary
|
||||
case .writeVideoMetadata: return Color.gradientPrimary
|
||||
case .saveToAlbum: return Color.gradientSuccess
|
||||
case .validate: return Color.gradientSuccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,12 +255,28 @@ struct ProcessingView: View {
|
||||
return "准备中..."
|
||||
}
|
||||
switch stage {
|
||||
case .normalize: return "预处理视频..."
|
||||
case .extractKeyFrame: return "提取封面帧..."
|
||||
case .writePhotoMetadata: return "写入图片元数据..."
|
||||
case .writeVideoMetadata: return "写入视频元数据..."
|
||||
case .saveToAlbum: return "保存到相册..."
|
||||
case .validate: return "校验 Live Photo..."
|
||||
case .normalize: return "预处理视频"
|
||||
case .extractKeyFrame: return "提取封面帧"
|
||||
case .aiEnhance: return "AI 增强封面"
|
||||
case .writePhotoMetadata: return "写入图片元数据"
|
||||
case .writeVideoMetadata: return "写入视频元数据"
|
||||
case .saveToAlbum: return "保存到相册"
|
||||
case .validate: return "校验 Live Photo"
|
||||
}
|
||||
}
|
||||
|
||||
private var stageDescription: String {
|
||||
guard let stage = appState.processingProgress?.stage else {
|
||||
return "正在初始化..."
|
||||
}
|
||||
switch stage {
|
||||
case .normalize: return "调整视频分辨率和帧率"
|
||||
case .extractKeyFrame: return "从视频中提取封面图片"
|
||||
case .aiEnhance: return "使用 AI 提升封面画质,约 2-3 秒"
|
||||
case .writePhotoMetadata: return "添加 Live Photo 必要的元数据"
|
||||
case .writeVideoMetadata: return "处理配对视频的元数据"
|
||||
case .saveToAlbum: return "正在保存到系统相册"
|
||||
case .validate: return "验证 Live Photo 是否正确生成"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,81 +13,256 @@ struct ResultView: View {
|
||||
|
||||
let workflowResult: LivePhotoWorkflowResult
|
||||
|
||||
@State private var showIcon = false
|
||||
@State private var showContent = false
|
||||
@State private var showButtons = false
|
||||
@State private var celebrationParticles = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
// 背景
|
||||
Color.softBackground.ignoresSafeArea()
|
||||
|
||||
Image(systemName: isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(isSuccess ? .green : .red)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(isSuccess ? "Live Photo 已保存" : "保存失败")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
|
||||
if isSuccess {
|
||||
Text("已保存到系统相册")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if workflowResult.resourceValidationOK {
|
||||
Label("资源校验通过", systemImage: "checkmark.seal.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
|
||||
if let isLive = workflowResult.libraryAssetIsLivePhoto, isLive {
|
||||
Label("相册识别为 Live Photo", systemImage: "livephoto")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
// 成功时的庆祝粒子效果
|
||||
if isSuccess && celebrationParticles {
|
||||
CelebrationParticles()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
VStack(spacing: DesignTokens.Spacing.xxxl) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
if isSuccess {
|
||||
Button {
|
||||
appState.navigateTo(.wallpaperGuide(assetId: workflowResult.savedAssetId))
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
Text("设置为动态壁纸")
|
||||
}
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
// 结果图标
|
||||
resultIcon
|
||||
|
||||
Button {
|
||||
appState.popToRoot()
|
||||
} label: {
|
||||
Text(isSuccess ? "继续制作" : "返回首页")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.2))
|
||||
.foregroundColor(.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
// 结果信息
|
||||
resultInfo
|
||||
|
||||
Spacer()
|
||||
|
||||
// 操作按钮
|
||||
actionButtons
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom)
|
||||
.padding(.horizontal, DesignTokens.Spacing.xxl)
|
||||
.padding(.bottom, DesignTokens.Spacing.xxl)
|
||||
}
|
||||
.navigationTitle("完成")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
animateIn()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 结果图标
|
||||
@ViewBuilder
|
||||
private var resultIcon: some View {
|
||||
ZStack {
|
||||
// 背景光晕
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
isSuccess ? Color.accentGreen.opacity(0.3) : Color.accentOrange.opacity(0.3),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 100
|
||||
)
|
||||
)
|
||||
.frame(width: showIcon ? 200 : 100, height: showIcon ? 200 : 100)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.6), value: showIcon)
|
||||
|
||||
// 图标容器
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSuccess ? Color.gradientSuccess : Color.gradientWarm)
|
||||
.frame(width: 100, height: 100)
|
||||
.shadow(
|
||||
color: (isSuccess ? Color.accentGreen : Color.accentOrange).opacity(0.4),
|
||||
radius: 20,
|
||||
x: 0,
|
||||
y: 10
|
||||
)
|
||||
|
||||
Image(systemName: isSuccess ? "checkmark" : "xmark")
|
||||
.font(.system(size: 44, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.scaleEffect(showIcon ? 1.0 : 0.5)
|
||||
.opacity(showIcon ? 1.0 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.6), value: showIcon)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 结果信息
|
||||
@ViewBuilder
|
||||
private var resultInfo: some View {
|
||||
VStack(spacing: DesignTokens.Spacing.lg) {
|
||||
Text(isSuccess ? "Live Photo 已保存" : "保存失败")
|
||||
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
|
||||
.foregroundColor(.textPrimary)
|
||||
|
||||
if isSuccess {
|
||||
Text("已保存到系统相册,可以设置为动态壁纸")
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// 验证状态
|
||||
HStack(spacing: DesignTokens.Spacing.lg) {
|
||||
if workflowResult.resourceValidationOK {
|
||||
ValidationBadge(icon: "checkmark.seal.fill", text: "资源校验", color: .accentGreen)
|
||||
}
|
||||
|
||||
if let isLive = workflowResult.libraryAssetIsLivePhoto, isLive {
|
||||
ValidationBadge(icon: "livephoto", text: "Live Photo", color: .accentPink)
|
||||
}
|
||||
}
|
||||
.padding(.top, DesignTokens.Spacing.sm)
|
||||
} else {
|
||||
Text("请返回重试或检查视频格式")
|
||||
.font(.system(size: DesignTokens.FontSize.base))
|
||||
.foregroundColor(.textSecondary)
|
||||
}
|
||||
}
|
||||
.opacity(showContent ? 1 : 0)
|
||||
.offset(y: showContent ? 0 : 20)
|
||||
.animation(.easeOut(duration: 0.4), value: showContent)
|
||||
}
|
||||
|
||||
// MARK: - 操作按钮
|
||||
@ViewBuilder
|
||||
private var actionButtons: some View {
|
||||
VStack(spacing: DesignTokens.Spacing.md) {
|
||||
if isSuccess {
|
||||
SoftPrimaryButton("设置为壁纸", icon: "photo.on.rectangle", gradient: Color.gradientPrimary) {
|
||||
appState.navigateTo(.wallpaperGuide(assetId: workflowResult.savedAssetId))
|
||||
}
|
||||
|
||||
SoftSecondaryButton("继续制作", icon: "plus.circle") {
|
||||
appState.popToRoot()
|
||||
}
|
||||
} else {
|
||||
SoftPrimaryButton("返回重试", icon: "arrow.counterclockwise", gradient: Color.gradientWarm) {
|
||||
appState.pop()
|
||||
}
|
||||
|
||||
SoftSecondaryButton("返回首页", icon: "house") {
|
||||
appState.popToRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(showButtons ? 1 : 0)
|
||||
.offset(y: showButtons ? 0 : 30)
|
||||
.animation(.easeOut(duration: 0.4), value: showButtons)
|
||||
}
|
||||
|
||||
// MARK: - 计算属性
|
||||
|
||||
private var isSuccess: Bool {
|
||||
!workflowResult.savedAssetId.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - 动画
|
||||
|
||||
private func animateIn() {
|
||||
// 串行动画
|
||||
withAnimation {
|
||||
showIcon = true
|
||||
}
|
||||
|
||||
if isSuccess {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
celebrationParticles = true
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
showContent = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
showButtons = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 验证徽章
|
||||
struct ValidationBadge: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DesignTokens.Spacing.xs) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 12))
|
||||
Text(text)
|
||||
.font(.system(size: DesignTokens.FontSize.xs, weight: .medium))
|
||||
}
|
||||
.foregroundColor(color)
|
||||
.padding(.horizontal, DesignTokens.Spacing.md)
|
||||
.padding(.vertical, DesignTokens.Spacing.sm)
|
||||
.background(color.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 庆祝粒子效果
|
||||
struct CelebrationParticles: View {
|
||||
@State private var particles: [Particle] = []
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ForEach(particles) { particle in
|
||||
Circle()
|
||||
.fill(particle.color)
|
||||
.frame(width: particle.size, height: particle.size)
|
||||
.position(particle.position)
|
||||
.opacity(particle.opacity)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
generateParticles()
|
||||
}
|
||||
}
|
||||
|
||||
private func generateParticles() {
|
||||
let colors: [Color] = [.accentPurple, .accentPink, .accentGreen, .accentCyan, .accentOrange]
|
||||
|
||||
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),
|
||||
color: colors.randomElement()!,
|
||||
size: CGFloat.random(in: 6...12),
|
||||
opacity: 1.0
|
||||
)
|
||||
particles.append(particle)
|
||||
|
||||
// 动画粒子向上飘动
|
||||
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.x += CGFloat.random(in: -50...50)
|
||||
particles[index].opacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Particle: Identifiable {
|
||||
let id: UUID
|
||||
var position: CGPoint
|
||||
let color: Color
|
||||
let size: CGFloat
|
||||
var opacity: Double
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
392
to-live-photo/to-live-photo/Views/SettingsView.swift
Normal file
392
to-live-photo/to-live-photo/Views/SettingsView.swift
Normal file
@@ -0,0 +1,392 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// to-live-photo
|
||||
//
|
||||
// 设置页:权限状态、清理缓存、反馈入口
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Photos
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var photoLibraryStatus: PHAuthorizationStatus = .notDetermined
|
||||
@State private var cacheSize: String = "计算中..."
|
||||
@State private var showingClearCacheAlert = false
|
||||
@State private var showingClearRecentWorksAlert = false
|
||||
@State private var feedbackPackageURL: URL?
|
||||
@State private var showingShareSheet = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// 权限状态
|
||||
Section {
|
||||
HStack {
|
||||
Label("相册权限", systemImage: "photo.on.rectangle")
|
||||
Spacer()
|
||||
permissionStatusView
|
||||
}
|
||||
|
||||
if photoLibraryStatus == .denied || photoLibraryStatus == .restricted {
|
||||
Button {
|
||||
openSettings()
|
||||
} label: {
|
||||
Label("前往设置授权", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("权限")
|
||||
} footer: {
|
||||
Text("需要相册权限才能保存 Live Photo")
|
||||
}
|
||||
|
||||
// 存储
|
||||
Section {
|
||||
HStack {
|
||||
Label("缓存大小", systemImage: "internaldrive")
|
||||
Spacer()
|
||||
Text(cacheSize)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingClearCacheAlert = true
|
||||
} label: {
|
||||
Label("清理缓存", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingClearRecentWorksAlert = true
|
||||
} label: {
|
||||
Label("清空最近作品记录", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
} header: {
|
||||
Text("存储")
|
||||
} footer: {
|
||||
Text("清理缓存不会影响已保存到相册的 Live Photo")
|
||||
}
|
||||
|
||||
// 反馈
|
||||
Section {
|
||||
Button {
|
||||
exportFeedbackPackage()
|
||||
} label: {
|
||||
Label("导出诊断报告", systemImage: "doc.text")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "mailto:support@let5see.xyz")!) {
|
||||
Label("反馈问题", systemImage: "envelope")
|
||||
}
|
||||
|
||||
// TODO: App Store 上架后替换为实际的 App ID
|
||||
Link(destination: URL(string: "https://apps.apple.com/app/id000000000")!) {
|
||||
Label("App Store 评分", systemImage: "star")
|
||||
}
|
||||
} header: {
|
||||
Text("反馈")
|
||||
} footer: {
|
||||
Text("诊断报告仅包含日志和参数,不含媒体内容")
|
||||
}
|
||||
|
||||
// 关于
|
||||
Section {
|
||||
HStack {
|
||||
Label("版本", systemImage: "info.circle")
|
||||
Spacer()
|
||||
Text(appVersion)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
PrivacyPolicyView()
|
||||
} label: {
|
||||
Label("隐私政策", systemImage: "hand.raised")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
TermsOfServiceView()
|
||||
} label: {
|
||||
Label("使用条款", systemImage: "doc.text")
|
||||
}
|
||||
} header: {
|
||||
Text("关于")
|
||||
}
|
||||
}
|
||||
.navigationTitle("设置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
checkPermissionStatus()
|
||||
calculateCacheSize()
|
||||
}
|
||||
.alert("清理缓存", isPresented: $showingClearCacheAlert) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("清理", role: .destructive) {
|
||||
clearCache()
|
||||
}
|
||||
} message: {
|
||||
Text("确定要清理所有缓存文件吗?")
|
||||
}
|
||||
.alert("清空记录", isPresented: $showingClearRecentWorksAlert) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("清空", role: .destructive) {
|
||||
clearRecentWorks()
|
||||
}
|
||||
} message: {
|
||||
Text("确定要清空最近作品记录吗?这不会删除相册中的 Live Photo。")
|
||||
}
|
||||
.sheet(isPresented: $showingShareSheet) {
|
||||
if let url = feedbackPackageURL {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var permissionStatusView: some View {
|
||||
switch photoLibraryStatus {
|
||||
case .authorized:
|
||||
Label("已授权", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.labelStyle(.iconOnly)
|
||||
case .limited:
|
||||
Label("部分授权", systemImage: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.labelStyle(.iconOnly)
|
||||
case .denied, .restricted:
|
||||
Label("未授权", systemImage: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.labelStyle(.iconOnly)
|
||||
case .notDetermined:
|
||||
Label("未确定", systemImage: "questionmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
.labelStyle(.iconOnly)
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var appVersion: String {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
return "\(version) (\(build))"
|
||||
}
|
||||
|
||||
private func checkPermissionStatus() {
|
||||
photoLibraryStatus = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
}
|
||||
|
||||
private func calculateCacheSize() {
|
||||
Task {
|
||||
let size = await getCacheDirectorySize()
|
||||
await MainActor.run {
|
||||
cacheSize = formatBytes(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getCacheDirectorySize() async -> Int64 {
|
||||
// 在同步上下文中执行文件系统操作
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let fileManager = FileManager.default
|
||||
guard let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
||||
continuation.resume(returning: 0)
|
||||
return
|
||||
}
|
||||
|
||||
let livePhotoCache = cachesDir.appendingPathComponent("LivePhotoBuilder")
|
||||
guard fileManager.fileExists(atPath: livePhotoCache.path) else {
|
||||
continuation.resume(returning: 0)
|
||||
return
|
||||
}
|
||||
|
||||
var totalSize: Int64 = 0
|
||||
if let enumerator = fileManager.enumerator(at: livePhotoCache, includingPropertiesForKeys: [.fileSizeKey]) {
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
totalSize += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.resume(returning: totalSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
|
||||
private func clearCache() {
|
||||
Task {
|
||||
let fileManager = FileManager.default
|
||||
guard let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
||||
return
|
||||
}
|
||||
|
||||
let livePhotoCache = cachesDir.appendingPathComponent("LivePhotoBuilder")
|
||||
try? fileManager.removeItem(at: livePhotoCache)
|
||||
|
||||
await MainActor.run {
|
||||
cacheSize = "0 KB"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearRecentWorks() {
|
||||
RecentWorksManager.shared.clearAll()
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private func exportFeedbackPackage() {
|
||||
Task {
|
||||
let url = await createFeedbackPackage()
|
||||
await MainActor.run {
|
||||
if let url {
|
||||
feedbackPackageURL = url
|
||||
showingShareSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集日志文件(同步方法,避免 Swift 6 异步上下文限制)
|
||||
private func collectLogFiles(from fileManager: FileManager, to packageDir: URL) -> Int {
|
||||
guard let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let livePhotoCache = cachesDir.appendingPathComponent("LivePhotoBuilder")
|
||||
guard fileManager.fileExists(atPath: livePhotoCache.path) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
guard let enumerator = fileManager.enumerator(at: livePhotoCache, includingPropertiesForKeys: [.isRegularFileKey]) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
var logCount = 0
|
||||
while let fileURL = enumerator.nextObject() as? URL {
|
||||
if fileURL.pathExtension == "log" && logCount < 5 {
|
||||
let destURL = packageDir.appendingPathComponent("log_\(logCount).log")
|
||||
try? fileManager.copyItem(at: fileURL, to: destURL)
|
||||
logCount += 1
|
||||
}
|
||||
}
|
||||
return logCount
|
||||
}
|
||||
|
||||
private func createFeedbackPackage() async -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.temporaryDirectory
|
||||
let packageDir = tempDir.appendingPathComponent("FeedbackPackage_\(Date().timeIntervalSince1970)")
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(at: packageDir, withIntermediateDirectories: true)
|
||||
|
||||
// 1. 收集系统信息
|
||||
var systemInfo = """
|
||||
# Live Photo Maker 诊断报告
|
||||
生成时间: \(Date().formatted())
|
||||
|
||||
## 系统信息
|
||||
- 设备: \(UIDevice.current.model)
|
||||
- 系统版本: \(UIDevice.current.systemName) \(UIDevice.current.systemVersion)
|
||||
- App 版本: \(appVersion)
|
||||
|
||||
## 权限状态
|
||||
- 相册权限: \(photoLibraryStatus.description)
|
||||
|
||||
"""
|
||||
|
||||
// 2. 收集最近作品记录(仅参数,不含媒体)
|
||||
let recentWorks = RecentWorksManager.shared.recentWorks
|
||||
systemInfo += "\n## 最近作品记录 (\(recentWorks.count) 条)\n"
|
||||
for (index, work) in recentWorks.prefix(10).enumerated() {
|
||||
systemInfo += """
|
||||
\(index + 1). ID: \(work.id.uuidString.prefix(8))...
|
||||
- 创建时间: \(work.createdAt.formatted())
|
||||
- 比例: \(work.aspectRatioDisplayName)
|
||||
- 兼容模式: \(work.compatibilityMode ? "是" : "否")
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
// 3. 收集错误统计(按 stage 归因)
|
||||
systemInfo += "\n## 错误统计\n"
|
||||
systemInfo += await MainActor.run {
|
||||
Analytics.shared.getErrorStatsSummary()
|
||||
}
|
||||
|
||||
// 4. 收集日志文件(在同步上下文中执行文件枚举)
|
||||
let logCount = collectLogFiles(from: fileManager, to: packageDir)
|
||||
systemInfo += "\n## 日志文件\n收集了 \(logCount) 个日志文件\n"
|
||||
|
||||
// 5. 写入系统信息文件
|
||||
let infoURL = packageDir.appendingPathComponent("diagnosis_report.md")
|
||||
try systemInfo.write(to: infoURL, atomically: true, encoding: .utf8)
|
||||
|
||||
// 6. 创建 zip 包
|
||||
let zipURL = tempDir.appendingPathComponent("LivePhotoMaker_Diagnosis_\(Date().timeIntervalSince1970).zip")
|
||||
let coordinator = NSFileCoordinator()
|
||||
var error: NSError?
|
||||
|
||||
coordinator.coordinate(readingItemAt: packageDir, options: .forUploading, error: &error) { zipTempURL in
|
||||
try? fileManager.copyItem(at: zipTempURL, to: zipURL)
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
try? fileManager.removeItem(at: packageDir)
|
||||
|
||||
if fileManager.fileExists(atPath: zipURL.path) {
|
||||
return zipURL
|
||||
}
|
||||
|
||||
// 如果 zip 失败,直接返回 md 文件
|
||||
let fallbackURL = tempDir.appendingPathComponent("diagnosis_report.md")
|
||||
try? systemInfo.write(to: fallbackURL, atomically: true, encoding: .utf8)
|
||||
return fallbackURL
|
||||
|
||||
} catch {
|
||||
print("[SettingsView] Failed to create feedback package: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Sheet
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user