fix: 1.0 发版前安全审查与优化修复 #3

Merged
let5see merged 9 commits from release/1.0-final-fixes into main 2026-02-07 20:20:11 +08:00
10 changed files with 10193 additions and 10050 deletions
Showing only changes of commit a49fee4b52 - Show all commits

View File

@@ -212,21 +212,25 @@ enum ImageFormatConverter {
bytesPerRow: Int
) -> [UInt8] {
var result = [UInt8](repeating: 0, count: width * height * 4)
let dstBytesPerRow = width * 4
for y in 0..<height {
let srcRow = baseAddress.advanced(by: y * bytesPerRow).assumingMemoryBound(to: UInt8.self)
let dstOffset = y * width * 4
var srcBuffer = vImage_Buffer(
data: baseAddress,
height: vImagePixelCount(height),
width: vImagePixelCount(width),
rowBytes: bytesPerRow
)
for x in 0..<width {
let srcIdx = x * 4
let dstIdx = dstOffset + x * 4
// BGRA -> RGBA swap
result[dstIdx + 0] = srcRow[srcIdx + 2] // R
result[dstIdx + 1] = srcRow[srcIdx + 1] // G
result[dstIdx + 2] = srcRow[srcIdx + 0] // B
result[dstIdx + 3] = srcRow[srcIdx + 3] // A
}
result.withUnsafeMutableBufferPointer { dstPtr in
var dstBuffer = vImage_Buffer(
data: dstPtr.baseAddress!,
height: vImagePixelCount(height),
width: vImagePixelCount(width),
rowBytes: dstBytesPerRow
)
// BGRA RGBA: [2,1,0,3]
let permuteMap: [UInt8] = [2, 1, 0, 3]
vImagePermuteChannels_ARGB8888(&srcBuffer, &dstBuffer, permuteMap, vImage_Flags(kvImageNoFlags))
}
return result
@@ -239,21 +243,25 @@ enum ImageFormatConverter {
bytesPerRow: Int
) -> [UInt8] {
var result = [UInt8](repeating: 0, count: width * height * 4)
let dstBytesPerRow = width * 4
for y in 0..<height {
let srcRow = baseAddress.advanced(by: y * bytesPerRow).assumingMemoryBound(to: UInt8.self)
let dstOffset = y * width * 4
var srcBuffer = vImage_Buffer(
data: baseAddress,
height: vImagePixelCount(height),
width: vImagePixelCount(width),
rowBytes: bytesPerRow
)
for x in 0..<width {
let srcIdx = x * 4
let dstIdx = dstOffset + x * 4
// ARGB -> RGBA swap
result[dstIdx + 0] = srcRow[srcIdx + 1] // R
result[dstIdx + 1] = srcRow[srcIdx + 2] // G
result[dstIdx + 2] = srcRow[srcIdx + 3] // B
result[dstIdx + 3] = srcRow[srcIdx + 0] // A
}
result.withUnsafeMutableBufferPointer { dstPtr in
var dstBuffer = vImage_Buffer(
data: dstPtr.baseAddress!,
height: vImagePixelCount(height),
width: vImagePixelCount(width),
rowBytes: dstBytesPerRow
)
// ARGB RGBA: [1,2,3,0]
let permuteMap: [UInt8] = [1, 2, 3, 0]
vImagePermuteChannels_ARGB8888(&srcBuffer, &dstBuffer, permuteMap, vImage_Flags(kvImageNoFlags))
}
return result

View File

@@ -260,6 +260,18 @@ public struct CacheManager: Sendable {
}
public func makeWorkPaths(workId: UUID) throws -> LivePhotoWorkPaths {
// 500MB
let resourceValues = try baseDirectory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
if let availableCapacity = resourceValues.volumeAvailableCapacityForImportantUsage,
availableCapacity < 500_000_000 {
throw AppError(
code: "LPB-001",
stage: .normalize,
message: String(localized: "error.insufficientDiskSpace"),
suggestedActions: [String(localized: "error.clearCache"), String(localized: "error.freeSpace")]
)
}
let workDir = baseDirectory.appendingPathComponent(workId.uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
return LivePhotoWorkPaths(
@@ -291,10 +303,14 @@ public struct LivePhotoLogger: Sendable {
self.logger = os.Logger(subsystem: subsystem, category: category)
}
///
/// - Important: 使 .public
public func info(_ message: String) {
logger.info("\(message, privacy: .public)")
}
///
/// - Important: 使 .public
public func error(_ message: String) {
logger.error("\(message, privacy: .public)")
}

View File

@@ -10,11 +10,14 @@ import LivePhotoCore
struct ContentView: View {
@Environment(AppState.self) private var appState
@Environment(\.scenePhase) private var scenePhase
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@State private var showPrivacyOverlay = false
var body: some View {
@Bindable var appState = appState
ZStack {
if !hasCompletedOnboarding {
OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)
} else {
@@ -38,6 +41,29 @@ struct ContentView: View {
}
}
}
// Privacy overlay for app switcher
if showPrivacyOverlay {
Color.softBackground
.ignoresSafeArea()
.overlay {
VStack(spacing: DesignTokens.Spacing.lg) {
Image(systemName: "livephoto")
.font(.system(size: 48))
.foregroundStyle(.tint)
Text("Live Photo Studio")
.font(.headline)
.foregroundColor(.textSecondary)
}
}
.transition(.opacity)
}
}
.onChange(of: scenePhase) { _, newPhase in
withAnimation(.easeInOut(duration: 0.2)) {
showPrivacyOverlay = newPhase != .active
}
}
}
}

View File

@@ -447,7 +447,7 @@ struct SoftProgressRing: View {
.animation(DesignTokens.Animation.smooth, value: progress)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel ?? String(localized: "进度"))
.accessibilityLabel(accessibilityLabel ?? String(localized: "accessibility.progress"))
.accessibilityValue(Text("\(Int(progress * 100))%"))
}
}
@@ -593,7 +593,7 @@ struct SoftSlider: View {
}
.frame(height: 28)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel)
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "accessibility.slider") : accessibilityLabel)
.accessibilityValue(Text(String(format: "%.1f", value)))
.accessibilityAdjustableAction { direction in
guard !isDisabled else { return }

View File

@@ -41,9 +41,15 @@
}
}
},
"%lld": {},
"%lld%%": {},
"•": {},
"%lld" : {
},
"%lld%%" : {
},
"•" : {
},
"accessibility.aspectRatio" : {
"extractionState" : "manual",
"localizations" : {
@@ -97,7 +103,9 @@
}
}
},
"accessibility.aspectRatio %@": {},
"accessibility.aspectRatio %@" : {
},
"accessibility.duration" : {
"extractionState" : "manual",
"localizations" : {
@@ -363,6 +371,271 @@
}
}
},
"aspectRatio.classic" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "4:3"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "4:3"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "4:3"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "4:3"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "4:3"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "4:3"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "4:3"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "4:3"
}
}
}
},
"aspectRatio.fullScreen" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "ملء الشاشة"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Full Screen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pantalla completa"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Plein écran"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "全画面"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "전체 화면"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "全屏"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "全屏"
}
}
}
},
"aspectRatio.lockScreen" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "شاشة القفل"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Lock Screen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pantalla de bloqueo"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Écran de verrouillage"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ロック画面"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "잠금 화면"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "锁屏"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "鎖屏"
}
}
}
},
"aspectRatio.original" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "الأصلي"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Original"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Original"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Original"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "オリジナル"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "원본 비율"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "原比例"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "原比例"
}
}
}
},
"aspectRatio.square" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "1:1"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "1:1"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "1:1"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "1:1"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "1:1"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "1:1"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "1:1"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "1:1"
}
}
}
},
"common.calculating" : {
"extractionState" : "manual",
"localizations" : {
@@ -3331,7 +3604,9 @@
}
}
},
"home.loadError %@": {},
"home.loadError %@" : {
},
"home.loadFailed" : {
"extractionState" : "manual",
"localizations" : {
@@ -4180,7 +4455,9 @@
}
}
},
"Live Photo": {},
"Live Photo" : {
},
"onboarding.aiEnhance.description" : {
"extractionState" : "manual",
"localizations" : {
@@ -5029,6 +5306,59 @@
}
}
},
"onboarding.page4.permissionHint" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "سيُطلب إذن الوصول إلى مكتبة الصور للحفظ"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Photo library access will be requested for saving"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se solicitará acceso a la biblioteca de fotos para guardar"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "L'accès à la photothèque sera demandé pour la sauvegarde"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "保存時にフォトライブラリへのアクセス許可が必要です"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "저장을 위해 사진 라이브러리 접근 권한이 요청됩니다"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "保存时需要访问相册权限"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "儲存時需要存取相簿權限"
}
}
}
},
"onboarding.page4.title" : {
"extractionState" : "manual",
"localizations" : {
@@ -9428,6 +9758,58 @@
}
}
},
"settings.applyAndRestart" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "تطبيق"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apply"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aplicar"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Appliquer"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "適用"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "적용"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "应用"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "套用"
}
}
}
},
"settings.authorized" : {
"extractionState" : "manual",
"localizations" : {
@@ -10064,59 +10446,6 @@
}
}
},
"settings.feedbackFooter": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "Diagnostics contain only logs and parameters, no media content"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Diagnostics contain only logs and parameters, no media content"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Diagnostics contain only logs and parameters, no media content"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Diagnostics contain only logs and parameters, no media content"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Diagnostics contain only logs and parameters, no media content"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "Diagnostics contain only logs and parameters, no media content"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "诊断报告仅包含日志和参数,不含媒体内容"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "診斷報告僅包含日誌和參數,不含媒體內容"
}
}
}
},
"settings.feedbackConfirmExport" : {
"extractionState" : "manual",
"localizations" : {
@@ -10276,6 +10605,59 @@
}
}
},
"settings.feedbackFooter" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "Diagnostics contain only logs and parameters, no media content"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Diagnostics contain only logs and parameters, no media content"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Diagnostics contain only logs and parameters, no media content"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Diagnostics contain only logs and parameters, no media content"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Diagnostics contain only logs and parameters, no media content"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Diagnostics contain only logs and parameters, no media content"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "诊断报告仅包含日志和参数,不含媒体内容"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "診斷報告僅包含日誌和參數,不含媒體內容"
}
}
}
},
"settings.goToSettings" : {
"extractionState" : "manual",
"localizations" : {
@@ -10963,58 +11345,6 @@
}
}
},
"settings.applyAndRestart": {
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "تطبيق"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Apply"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Aplicar"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Appliquer"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "適用"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "적용"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "应用"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "套用"
}
}
}
},
"settings.storage" : {
"extractionState" : "manual",
"localizations" : {
@@ -14778,7 +15108,6 @@
}
}
},
"跳过": {},
"进度" : {
"extractionState" : "manual",
"localizations" : {
@@ -14831,271 +15160,6 @@
}
}
}
},
"aspectRatio.original": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "原比例"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "原比例"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Original"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Original"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "الأصلي"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Original"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "オリジナル"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "원본 비율"
}
}
}
},
"aspectRatio.lockScreen": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "锁屏"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "鎖屏"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Lock Screen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Pantalla de bloqueo"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "شاشة القفل"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Écran de verrouillage"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ロック画面"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "잠금 화면"
}
}
}
},
"aspectRatio.fullScreen": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "全屏"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "全屏"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Full Screen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Pantalla completa"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "ملء الشاشة"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Plein écran"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "全画面"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "전체 화면"
}
}
}
},
"aspectRatio.classic": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "4:3"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "4:3"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "4:3"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "4:3"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "4:3"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "4:3"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "4:3"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "4:3"
}
}
}
},
"aspectRatio.square": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "1:1"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "1:1"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "1:1"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "1:1"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "1:1"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "1:1"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1:1"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "1:1"
}
}
}
}
},
"version" : "1.0"

View File

@@ -119,7 +119,7 @@ struct EditorView: View {
}
.padding(.vertical, DesignTokens.Spacing.lg)
}
.frame(maxWidth: 360)
.frame(minWidth: 320, maxWidth: 420)
}
.padding(DesignTokens.Spacing.xxl)
}
@@ -593,7 +593,7 @@ struct EditorView: View {
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.background(Color.gradientPrimary)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
@@ -824,10 +824,10 @@ struct AspectRatioButton: View {
VStack(spacing: 4) {
//
RoundedRectangle(cornerRadius: 4)
.stroke(isSelected ? Color.accentColor : Color.textSecondary, lineWidth: 2)
.stroke(isSelected ? Color.accentPurple : Color.textSecondary, lineWidth: 2)
.frame(width: iconWidth, height: iconHeight)
.background(
isSelected ? Color.accentColor.opacity(0.1) : Color.clear
isSelected ? Color.accentPurple.opacity(0.1) : Color.clear
)
.clipShape(RoundedRectangle(cornerRadius: 4))
@@ -838,7 +838,7 @@ struct AspectRatioButton: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, DesignTokens.Spacing.sm)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.background(isSelected ? Color.accentPurple.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
}
.buttonStyle(.plain)

View File

@@ -113,6 +113,23 @@ struct OnboardingView: View {
.foregroundColor(.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
//
if index == 3 {
HStack(spacing: 6) {
Image(systemName: "lock.shield")
.font(.caption)
.foregroundColor(.accentGreen)
Text(String(localized: "onboarding.page4.permissionHint"))
.font(.caption)
.foregroundColor(.textMuted)
.multilineTextAlignment(.center)
}
.padding(.horizontal, DesignTokens.Spacing.lg)
.padding(.vertical, DesignTokens.Spacing.sm)
.background(Color.accentGreen.opacity(0.1))
.clipShape(Capsule())
}
}
.padding(.horizontal, DesignTokens.Spacing.xxxl)
@@ -134,6 +151,9 @@ struct OnboardingView: View {
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentPage)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "accessibility.pageIndicator"))
.accessibilityValue(String(localized: "accessibility.pageOf \(currentPage + 1) \(pages.count)"))
.padding(.bottom, DesignTokens.Spacing.md)
//

View File

@@ -138,6 +138,8 @@ struct ProcessingView: View {
.animation(.spring(response: 0.3), value: currentStageIndex)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "accessibility.processingStage"))
.padding(.top, DesignTokens.Spacing.md)
}
}

View File

@@ -26,6 +26,7 @@ struct ResultView: View {
//
if isSuccess && celebrationParticles {
CelebrationParticles()
.accessibilityHidden(true)
}
VStack(spacing: DesignTokens.Spacing.xxxl) {
@@ -223,14 +224,14 @@ struct CelebrationParticles: View {
.position(particle.position)
.opacity(particle.opacity)
}
.onAppear {
.task {
viewSize = geometry.size
generateParticles(in: geometry.size)
await generateParticles(in: geometry.size)
}
}
}
private func generateParticles(in size: CGSize) {
private func generateParticles(in size: CGSize) async {
let colors: [Color] = [.accentPurple, .accentPink, .accentGreen, .accentCyan, .accentOrange]
// 使
@@ -241,9 +242,14 @@ struct CelebrationParticles: View {
let flyDistance = size.height * 0.4 // 40%
for i in 0..<30 {
let delay = Double(i) * 0.03
let delayNanoseconds = UInt64(Double(i) * 0.03 * 1_000_000_000)
do {
try await Task.sleep(nanoseconds: delayNanoseconds)
} catch {
return // Task was cancelled (view destroyed)
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
let particle = Particle(
id: UUID(),
position: CGPoint(x: CGFloat.random(in: minX...maxX), y: startY),
@@ -264,7 +270,6 @@ struct CelebrationParticles: View {
}
}
}
}
struct Particle: Identifiable {
let id: UUID

View File

@@ -225,7 +225,7 @@ struct WallpaperGuideView: View {
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.background(Color.gradientPrimary)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
@@ -250,7 +250,7 @@ struct StepRow: View {
VStack(spacing: 0) {
ZStack {
Circle()
.fill(Color.accentColor)
.fill(Color.accentPurple)
.frame(width: 32, height: 32)
Text("\(number)")
.font(.subheadline)
@@ -260,7 +260,7 @@ struct StepRow: View {
if !isLast {
Rectangle()
.fill(Color.accentColor.opacity(0.3))
.fill(Color.accentPurple.opacity(0.3))
.frame(width: 2)
.frame(maxHeight: .infinity)
}
@@ -283,6 +283,7 @@ struct StepRow: View {
}
.padding(.bottom, isLast ? 0 : 16)
}
.accessibilityElement(children: .combine)
}
}
@@ -312,6 +313,7 @@ struct FAQRow: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
.accessibilityElement(children: .combine)
}
}