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:
empty
2025-12-16 10:24:31 +08:00
parent 64cdb82459
commit 5aba93e967
46 changed files with 5279 additions and 421 deletions

View File

@@ -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;

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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()
}
}
}
}
}
}
}

View 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)
}

View 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
}
}
}
}

View File

@@ -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"))

View File

@@ -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()

View 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))
}

View 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()
}
}

View File

@@ -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 是否正确生成"
}
}

View File

@@ -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 {

View 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()
}
}