// DisplayView.swift // StikJIT // // Created by neoarz on 4/9/25. import SwiftUI import UIKit import UniformTypeIdentifiers // MARK: - Accent Color Picker (Glassy style) struct AccentColorPicker: View { @Binding var selectedColor: Color let colors: [Color] = [ .blue, .init(hex: "#7FFFD4")!, .init(hex: "#50C878")!, .red, .init(hex: "#6A5ACD")!, .init(hex: "#DA70D6")!, .white, .black ] var body: some View { VStack(alignment: .leading, spacing: 14) { Text("Accent Color") .font(.headline) .foregroundColor(.primary) LazyVGrid(columns: Array(repeating: .init(.flexible(), spacing: 12), count: 9), spacing: 12) { ForEach(colors, id: \.self) { color in Circle() .fill(color) .frame(width: 28, height: 28) .overlay( Circle().stroke(Color.white.opacity(0.1), lineWidth: 1) ) .overlay( Circle().stroke(selectedColor == color ? Color.primary : .clear, lineWidth: 2) ) .onTapGesture { selectedColor = color } } ColorPicker("", selection: $selectedColor) .labelsHidden() .frame(width: 28, height: 28) .overlay( Circle().stroke(Color.white.opacity(0.1), lineWidth: 1) ) .clipShape(Circle()) } .frame(maxWidth: .infinity) } .padding(20) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) ) ) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } } // MARK: - Display Settings View struct DisplayView: View { @AppStorage("username") private var username = "User" @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true @State private var selectedAccentColor: Color = .blue @Environment(\.colorScheme) private var colorScheme @Environment(\.themeExpansionManager) private var themeExpansionOptional @State private var justSaved = false @State private var showingCreateCustomTheme = false @State private var editingCustomTheme: CustomTheme? private var themeExpansion: ThemeExpansionManager? { themeExpansionOptional } private var hasThemeExpansion: Bool { themeExpansion?.hasThemeExpansion == true } private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } private var tintColor: Color { hasThemeExpansion ? selectedAccentColor : .blue } private var selectedThemeIdentifier: String { appThemeRaw } private var selectedBuiltInTheme: AppTheme? { AppTheme(rawValue: selectedThemeIdentifier) } private var selectedCustomTheme: CustomTheme? { themeExpansion?.customTheme(for: selectedThemeIdentifier) } private var selectedThemeName: String { if let custom = selectedCustomTheme { return custom.name } return selectedBuiltInTheme?.displayName ?? "Theme" } private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: selectedThemeIdentifier) ?? AppTheme.system.backgroundStyle } private var shouldShowThemeExpansionUpsell: Bool { themeExpansion?.shouldShowThemeExpansionUpsell ?? true } var body: some View { NavigationStack { ZStack { ThemedBackground(style: backgroundStyle) .ignoresSafeArea() ScrollView { VStack(spacing: 20) { usernameCard if hasThemeExpansion { accentCard themeCard customThemesSection } else if shouldShowThemeExpansionUpsell { // Accent preview remains above accentPreview // Built-in + Custom themes previews with paywall centered on top ZStack(alignment: .center) { VStack(spacing: 20) { themePreview customThemesPreview } .zIndex(0) themeExpansionUpsellCard .frame(maxWidth: .infinity) // match other cards’ width .zIndex(1) } } jitOptionsCard } .padding(.horizontal, 20) .padding(.vertical, 30) } if justSaved { VStack { Spacer() Text("Saved") .font(.footnote.weight(.semibold)) .padding(.horizontal, 14) .padding(.vertical, 10) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) .transition(.move(edge: .bottom).combined(with: .opacity)) .padding(.bottom, 30) } .animation(.easeInOut(duration: 0.25), value: justSaved) } } .navigationTitle("Display") .onAppear { if !hasThemeExpansion, let manager = themeExpansion, manager.isCustomThemeIdentifier(appThemeRaw) { appThemeRaw = AppTheme.system.rawValue } loadCustomAccentColor() applyThemePreferences() } .onChange(of: appThemeRaw) { _, newValue in guard hasThemeExpansion, let manager = themeExpansion else { return } if manager.isCustomThemeIdentifier(newValue), manager.customTheme(for: newValue) == nil { appThemeRaw = AppTheme.system.rawValue } applyThemePreferences() } .onChange(of: themeExpansion?.hasThemeExpansion ?? false) { unlocked in if unlocked { loadCustomAccentColor() applyThemePreferences() } else { selectedAccentColor = .blue appThemeRaw = AppTheme.system.rawValue applyThemePreferences() } } } .tint(tintColor) .sheet(isPresented: $showingCreateCustomTheme) { CustomThemeEditorView(initialTheme: nil) { newTheme in themeExpansion?.upsert(customTheme: newTheme) if let manager = themeExpansion { appThemeRaw = manager.customThemeIdentifier(for: newTheme) } applyThemePreferences() showSavedToast() } } .sheet(item: $editingCustomTheme) { theme in CustomThemeEditorView(initialTheme: theme, onSave: { updated in themeExpansion?.upsert(customTheme: updated) if let manager = themeExpansion { appThemeRaw = manager.customThemeIdentifier(for: updated) } applyThemePreferences() showSavedToast() }, onDelete: { if let manager = themeExpansion { manager.delete(customTheme: theme) if manager.customThemeIdentifier(for: theme) == appThemeRaw { appThemeRaw = AppTheme.system.rawValue applyThemePreferences() } } }) } } // MARK: - Cards private var usernameCard: some View { appGlassCard { VStack(alignment: .leading, spacing: 12) { Text("Username") .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) HStack { TextField("Username", text: $username) .font(.body) .foregroundColor(.primary) .padding(.vertical, 8) if !username.isEmpty { Button(action: { username = "" showSavedToast() }) { Image(systemName: "xmark.circle.fill") .foregroundColor(Color(UIColor.tertiaryLabel)) .font(.system(size: 16)) } } } .padding(.horizontal, 12) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) ) } } } private var accentCard: some View { appGlassCard { VStack(alignment: .leading, spacing: 12) { Text("Accent") .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) AccentColorPicker(selectedColor: $selectedAccentColor) HStack(spacing: 12) { Button { if let hex = selectedAccentColor.toHex() { customAccentColorHex = hex } else { customAccentColorHex = "" } showSavedToast() } label: { HStack { Image(systemName: "checkmark.circle.fill") Text("Save") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(selectedAccentColor) ) .foregroundColor(selectedAccentColor.contrastText()) } Button { customAccentColorHex = "" selectedAccentColor = .blue showSavedToast() } label: { HStack { Image(systemName: "arrow.uturn.backward.circle") Text("Reset") } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(UIColor.tertiarySystemBackground)) ) } } } } } private var themeExpansionUpsellCard: some View { let isAppStore = themeExpansion?.isAppStoreBuild ?? true let productLoaded = themeExpansion?.themeExpansionProduct != nil return appGlassCard { VStack(alignment: .leading, spacing: 14) { Text("StikDebug Theme Expansion") .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) if !isAppStore { Text("Theme Expansion is coming soon on this store.") .font(.body) .foregroundColor(.secondary) Text("For now, you can continue using the default theme.") .font(.footnote) .foregroundColor(.secondary) } else { Text("Unlock custom accent colors and dynamic backgrounds with the Theme Expansion.") .font(.body) .foregroundColor(.secondary) if let price = themeExpansion?.themeExpansionProduct?.displayPrice { Text("One-time purchase • \(price)") .font(.subheadline) .foregroundColor(.secondary) } if productLoaded, let manager = themeExpansion { Button { Task { await manager.purchaseThemeExpansion() } } label: { HStack { if manager.isProcessing { ProgressView() .progressViewStyle(.circular) } Text(manager.isProcessing ? "Purchasing…" : "Unlock Theme Expansion") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.blue) ) .foregroundColor(Color.blue.contrastText()) } .disabled(manager.isProcessing) } else if let manager = themeExpansion { Button { Task { await manager.refreshEntitlements() } } label: { HStack { if manager.isProcessing { ProgressView() .progressViewStyle(.circular) } Text(manager.isProcessing ? "Contacting App Store…" : "Try Again") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.blue.opacity(0.4), lineWidth: 1) ) } .disabled(manager.isProcessing) } if let manager = themeExpansion { Button { Task { await manager.restorePurchases() } } label: { Text("Restore Purchase") .font(.subheadline) .fontWeight(.semibold) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.blue.opacity(0.4), lineWidth: 1) ) } .disabled(manager.isProcessing) } if let manager = themeExpansion, !productLoaded, manager.lastError == nil { Text(manager.isProcessing ? "Contacting the App Store…" : "Waiting for App Store information.") .font(.footnote) .foregroundColor(.secondary) } if let error = themeExpansion?.lastError { Text(error) .font(.footnote) .foregroundColor(.red) } } } } .task { if let manager = themeExpansion, manager.isAppStoreBuild, !manager.isProcessing, manager.themeExpansionProduct == nil, manager.lastError == nil { await manager.refreshEntitlements() } } } private var jitOptionsCard: some View { appGlassCard { VStack(alignment: .leading, spacing: 12) { Text("App List") .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) VStack(alignment: .leading, spacing: 6) { Toggle("Load App Icons", isOn: $loadAppIconsOnJIT) .tint(accentColor) Text("Disabling this will hide app icons in the app list and may improve performance, while also giving it a more minimalistic look.") .font(.footnote) .foregroundColor(.secondary) } } } } private var themeCard: some View { appGlassCard { VStack(alignment: .leading, spacing: 16) { Text("Themes") .font(.title3.weight(.semibold)) .foregroundColor(.primary) selectedThemePreview Divider() builtInThemesGrid(interactive: hasThemeExpansion, locked: !hasThemeExpansion) } } } private var selectedThemePreview: some View { ThemePreviewCard(style: backgroundStyle, title: selectedThemeName, selected: true, action: {}, staticPreview: false, allowsInteraction: false, height: 160) .accessibilityHidden(true) } private var themeLockedPreviewCard: some View { appGlassCard { VStack(alignment: .leading, spacing: 16) { Text("Themes") .font(.title3.weight(.semibold)) .foregroundColor(.primary) builtInThemesGrid(interactive: false, locked: true) } } } private var gridColumns: [GridItem] { Array(repeating: GridItem(.flexible(), spacing: 10), count: 2) } @ViewBuilder private func builtInThemesGrid(interactive: Bool, locked: Bool) -> some View { LazyVGrid(columns: gridColumns, spacing: 12) { ForEach(AppTheme.allCases, id: \.self) { theme in let isSelected = selectedBuiltInTheme == theme && selectedCustomTheme == nil ThemeOptionTile(style: theme.backgroundStyle, title: theme.displayName, isSelected: isSelected, isLocked: locked, interactive: interactive) { guard hasThemeExpansion else { return } appThemeRaw = theme.rawValue applyThemePreferences() showSavedToast() } } } } @ViewBuilder private var customThemesSection: some View { if hasThemeExpansion, let manager = themeExpansion { appGlassCard { VStack(alignment: .leading, spacing: 16) { HStack { Text("Custom Themes") .font(.title3.weight(.semibold)) .foregroundColor(.primary) Spacer() Button { showingCreateCustomTheme = true } label: { Label("New", systemImage: "plus.circle.fill") .font(.subheadline.weight(.semibold)) } } if manager.customThemes.isEmpty { VStack(spacing: 8) { Text("Create your own themes with custom colors and motion.") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.leading) Button(action: { showingCreateCustomTheme = true }) { Text("Create a Custom Theme") .font(.subheadline.weight(.semibold)) .padding(.vertical, 10) .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.blue) ) .foregroundColor(Color.blue.contrastText()) } } } else { LazyVGrid(columns: gridColumns, spacing: 12) { ForEach(manager.customThemes, id: \.id) { theme in let identifier = manager.customThemeIdentifier(for: theme) let isSelected = selectedCustomTheme?.id == theme.id ThemeOptionTile(style: manager.backgroundStyle(for: identifier), title: theme.name, isSelected: isSelected, isLocked: false, interactive: true) { appThemeRaw = identifier applyThemePreferences() showSavedToast() } .contextMenu { Button("Edit") { editingCustomTheme = theme } Button("Delete", role: .destructive) { manager.delete(customTheme: theme) let id = manager.customThemeIdentifier(for: theme) if appThemeRaw == id { appThemeRaw = AppTheme.system.rawValue applyThemePreferences() } } } } } } } } } } // MARK: - Helpers private func loadCustomAccentColor() { selectedAccentColor = themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } private func applyThemePreferences() { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first else { return } let scheme = themeExpansion?.preferredColorScheme(for: selectedThemeIdentifier) switch scheme { case .some(.dark): window.overrideUserInterfaceStyle = .dark case .some(.light): window.overrideUserInterfaceStyle = .light default: window.overrideUserInterfaceStyle = .unspecified } } private func showSavedToast() { withAnimation { justSaved = true } DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { withAnimation { justSaved = false } } } // MARK: - Paywall previews (optimized look, non-interactive) private func lockedPreview(_ content: Content) -> some View { content // Hardware-optimized blur via system material over the content .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(.ultraThinMaterial) .opacity(0.4) // more transparent blur for a stronger preview ) // Slight dim to improve contrast and preserve the “locked” feel .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(Color.black.opacity(0.03)) // lighter dim for more visibility ) .overlay(alignment: .topLeading) { HStack(spacing: 6) { Image(systemName: "lock.fill") Text("Preview") .fontWeight(.semibold) } .font(.caption2) .padding(.horizontal, 10) .padding(.vertical, 6) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().stroke(Color.white.opacity(0.15), lineWidth: 1)) .padding(12) } .compositingGroup() // flatten for better GPU compositing .allowsHitTesting(false) } // Use static theme grid inside the locked preview to avoid animation cost private var accentPreview: some View { lockedPreview(accentCard) } private var themePreview: some View { lockedPreview(themeLockedPreviewCard) } private var customThemesPreview: some View { lockedPreview(customThemesPreviewCard) } private var customThemesPreviewCard: some View { appGlassCard { VStack(alignment: .leading, spacing: 16) { HStack { Text("Custom Themes") .font(.title3.weight(.semibold)) .foregroundColor(.primary) Spacer() Label("New", systemImage: "plus.circle.fill") .font(.subheadline.weight(.semibold)) .opacity(0.6) } LazyVGrid(columns: gridColumns, spacing: 12) { ThemeOptionTile(style: .customGradient(colors: [Color(hex: "#3E4C7C") ?? .indigo, Color(hex: "#1C1F3A") ?? .blue]), title: "Midnight Fade", isSelected: false, isLocked: true, interactive: false, action: {}) ThemeOptionTile(style: .customGradient(colors: [Color(hex: "#00F5A0") ?? .green, Color(hex: "#00D9F5") ?? .cyan, Color(hex: "#C96BFF") ?? .purple]), title: "Neon Drift", isSelected: false, isLocked: true, interactive: false, action: {}) } } } } } // MARK: - Theme Option Tile & Preview Card private struct ThemeOptionTile: View { @Environment(\.colorScheme) private var colorScheme let style: BackgroundStyle let title: String let isSelected: Bool let isLocked: Bool let interactive: Bool let action: () -> Void private var borderColor: Color { if isSelected { return .accentColor } if isLocked { return Color.black.opacity(0.08) } return Color.black.opacity(0.12) } var body: some View { let tile = ZStack(alignment: .bottomLeading) { ThemePreviewThumbnail(style: style, colorScheme: colorScheme) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.black.opacity(0.12)) ) HStack { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.footnote.weight(.semibold)) .foregroundColor(.white) if isLocked { Text("Locked") .font(.caption2.weight(.semibold)) .foregroundColor(.white.opacity(0.8)) } } Spacer() if isLocked { Image(systemName: "lock.fill") .foregroundColor(.white.opacity(0.85)) .font(.caption.weight(.bold)) } else if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundColor(.white) .font(.title3.weight(.bold)) } } .padding(12) } .frame(height: 110) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) ) .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) .opacity(isLocked ? 0.85 : 1) if interactive && !isLocked { Button(action: action) { tile } .buttonStyle(.plain) } else { tile } } } private struct ThemePreviewCard: View { let style: BackgroundStyle let title: String let selected: Bool let action: () -> Void var staticPreview: Bool = false var allowsInteraction: Bool = true var height: CGFloat = 120 @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion private func staticized(_ style: BackgroundStyle) -> BackgroundStyle { switch style { case .staticGradient(let colors): return .staticGradient(colors: colors) case .animatedGradient(let colors, _): return .staticGradient(colors: colors) case .blobs(_, let background): // Use the background gradient for a static look return .staticGradient(colors: background) case .particles(_, let background): // Use the background gradient for a static look return .staticGradient(colors: background) case .customGradient(let colors): return .customGradient(colors: colors) case .adaptiveGradient(let light, let dark): let colors = colorScheme == .dark ? dark : light return .staticGradient(colors: colors) } } var body: some View { Group { if allowsInteraction { Button(action: action) { cardBody } .buttonStyle(.plain) } else { cardBody } } } private var cardBody: some View { ZStack { backgroundContent .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(.ultraThinMaterial) .padding(6) .opacity(0.55) ) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) VStack(spacing: 6) { Text(title) .font(.footnote.weight(.semibold)) .foregroundColor(.primary) .padding(.horizontal, 8) .padding(.vertical, 6) .background(.ultraThinMaterial, in: Capsule()) } .padding(8) } .frame(height: height) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(selected ? Color.accentColor : Color.white.opacity(0.12), lineWidth: selected ? 2 : 1) ) .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) } private var backgroundContent: some View { Group { if staticPreview { ThemePreviewThumbnail(style: staticized(style), colorScheme: colorScheme) } else { UIKitThemeBackground(style: style, reduceMotion: reduceMotion, colorScheme: colorScheme) } } } } // MARK: - UIKit-powered background previews private struct UIKitThemeBackground: UIViewRepresentable { let style: BackgroundStyle let reduceMotion: Bool let colorScheme: ColorScheme func makeUIView(context: Context) -> ThemePreviewUIKitView { ThemePreviewUIKitView() } func updateUIView(_ uiView: ThemePreviewUIKitView, context: Context) { uiView.configure(style: style, reduceMotion: reduceMotion, interfaceStyle: colorScheme) } } private final class ThemePreviewUIKitView: UIView { private let gradientLayer = CAGradientLayer() private var emitterLayer: CAEmitterLayer? private var currentConfigurationKey: String? override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = true layer.cornerCurve = .continuous layer.cornerRadius = 16 gradientLayer.startPoint = CGPoint(x: 0, y: 0) gradientLayer.endPoint = CGPoint(x: 1, y: 1) layer.addSublayer(gradientLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds emitterLayer?.emitterPosition = CGPoint(x: bounds.midX, y: bounds.midY) emitterLayer?.emitterSize = bounds.size } func configure(style: BackgroundStyle, reduceMotion: Bool, interfaceStyle: ColorScheme) { let key = configurationKey(for: style, reduceMotion: reduceMotion, interfaceStyle: interfaceStyle) guard key != currentConfigurationKey else { return } currentConfigurationKey = key gradientLayer.removeAllAnimations() emitterLayer?.removeFromSuperlayer() emitterLayer = nil switch style { case .staticGradient(let colors): applyGradient(colors: colors) case .animatedGradient(let colors, let speed): applyAnimatedGradient(colors: colors, speed: speed, reduceMotion: reduceMotion) case .blobs(_, let background): // UIKit snapshot of blobs can be heavy; fall back to background gradient for previews. applyGradient(colors: background) case .particles(let particle, let background): applyGradient(colors: background) applyParticleOverlay(color: particle, reduceMotion: reduceMotion) case .customGradient(let colors): applyGradient(colors: colors) case .adaptiveGradient(let light, let dark): let palette = interfaceStyle == .dark ? dark : light applyGradient(colors: palette) } } private func applyGradient(colors: [Color]) { gradientLayer.colors = colors.nonEmptyOrFallback().map { UIColor($0).cgColor } } private func applyAnimatedGradient(colors: [Color], speed: Double, reduceMotion: Bool) { applyGradient(colors: colors) guard !reduceMotion else { return } let duration = max(8.0, 18.0 / max(speed, 0.02)) let startAnimation = CABasicAnimation(keyPath: "startPoint") startAnimation.fromValue = CGPoint(x: 0, y: 0) startAnimation.toValue = CGPoint(x: 1, y: 1) startAnimation.duration = duration startAnimation.autoreverses = true startAnimation.repeatCount = .infinity startAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) let endAnimation = CABasicAnimation(keyPath: "endPoint") endAnimation.fromValue = CGPoint(x: 1, y: 1) endAnimation.toValue = CGPoint(x: 0, y: 0) endAnimation.duration = duration endAnimation.autoreverses = true endAnimation.repeatCount = .infinity endAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) gradientLayer.add(startAnimation, forKey: "startPoint") gradientLayer.add(endAnimation, forKey: "endPoint") } private func applyParticleOverlay(color: Color, reduceMotion: Bool) { guard !reduceMotion else { return } let emitter = CAEmitterLayer() emitter.emitterShape = .rectangle emitter.emitterMode = .surface emitter.renderMode = .additive emitter.emitterCells = [makeParticleCell(color: color)] layer.addSublayer(emitter) emitterLayer = emitter setNeedsLayout() } private func makeParticleCell(color: Color) -> CAEmitterCell { let cell = CAEmitterCell() cell.birthRate = 25 cell.lifetime = 18 cell.velocity = 12 cell.velocityRange = 8 cell.scale = 0.015 cell.scaleRange = 0.01 cell.alphaSpeed = -0.02 cell.contents = particleImage(color: color).cgImage return cell } private func particleImage(color: Color) -> UIImage { let size: CGFloat = 6 let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) return renderer.image { ctx in let rect = CGRect(x: 0, y: 0, width: size, height: size) ctx.cgContext.setFillColor(UIColor(color).withAlphaComponent(0.9).cgColor) ctx.cgContext.fillEllipse(in: rect) } } private func configurationKey(for style: BackgroundStyle, reduceMotion: Bool, interfaceStyle: ColorScheme) -> String { let schemeKey = interfaceStyle == .dark ? "dark" : "light" return "\(style.previewIdentityKey(for: interfaceStyle))|motion:\(reduceMotion)|scheme:\(schemeKey)" } } private extension Array where Element == Color { func nonEmptyOrFallback() -> [Color] { if isEmpty { return [Color.blue, Color.purple] } if count == 1 { return [self[0], self[0].opacity(0.7)] } return self } func previewIdentityKey() -> String { map { $0.previewIdentityKey }.joined(separator: ",") } } private extension Color { var previewIdentityKey: String { if let hex = toHex() { return hex } return String(describing: self) } } private extension BackgroundStyle { func previewIdentityKey(for scheme: ColorScheme) -> String { switch self { case .staticGradient(let colors): return "static:\(colors.previewIdentityKey())" case .animatedGradient(let colors, let speed): return "animated:\(String(format: "%.4f", speed)):\(colors.previewIdentityKey())" case .blobs(let colors, let background): return "blobs:\(colors.previewIdentityKey())|bg:\(background.previewIdentityKey())" case .particles(let particle, let background): return "particles:\(particle.previewIdentityKey)|bg:\(background.previewIdentityKey())" case .customGradient(let colors): return "custom:\(colors.previewIdentityKey())" case .adaptiveGradient(let light, let dark): let palette = scheme == .dark ? dark : light return "adaptive:\(palette.previewIdentityKey())" } } func thumbnailColors(for scheme: ColorScheme) -> [Color] { switch self { case .staticGradient(let colors): return colors case .animatedGradient(let colors, _): return colors case .blobs(_, let background): return background case .particles(_, let background): return background case .customGradient(let colors): return colors case .adaptiveGradient(let light, let dark): return scheme == .dark ? dark : light } } } private struct ThemePreviewThumbnail: View { let style: BackgroundStyle let colorScheme: ColorScheme var cornerRadius: CGFloat = 16 @State private var image: UIImage? private var cacheKey: String { style.previewIdentityKey(for: colorScheme) } var body: some View { ZStack { if let image { Image(uiImage: image) .resizable() .scaledToFill() } else { placeholderGradient } } .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .task(id: cacheKey) { image = await ThemePreviewThumbnailCache.shared.image(for: style, scheme: colorScheme) } } private var placeholderGradient: some View { LinearGradient(colors: style.thumbnailColors(for: colorScheme).nonEmptyOrFallback(), startPoint: .topLeading, endPoint: .bottomTrailing) } } private final class ThemePreviewThumbnailCache { static let shared = ThemePreviewThumbnailCache() private let cache = NSCache() private let queue = DispatchQueue(label: "ThemePreviewThumbnailCache", qos: .userInitiated) private let renderSize = CGSize(width: 320, height: 200) func image(for style: BackgroundStyle, scheme: ColorScheme) async -> UIImage { let key = style.previewIdentityKey(for: scheme) as NSString if let cached = cache.object(forKey: key) { return cached } return await withCheckedContinuation { continuation in queue.async { let image = self.drawThumbnail(style: style, scheme: scheme) self.cache.setObject(image, forKey: key) continuation.resume(returning: image) } } } private func drawThumbnail(style: BackgroundStyle, scheme: ColorScheme) -> UIImage { let colors = style.thumbnailColors(for: scheme).nonEmptyOrFallback() let uiColors = colors.map { UIColor($0) } let renderer = UIGraphicsImageRenderer(size: renderSize) return renderer.image { ctx in guard let gradient = CGGradient( colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: uiColors.map { $0.cgColor } as CFArray, locations: nil ) else { ctx.cgContext.setFillColor(uiColors.first?.cgColor ?? UIColor.systemBackground.cgColor) ctx.cgContext.fill(CGRect(origin: .zero, size: renderSize)) return } ctx.cgContext.drawLinearGradient( gradient, start: CGPoint(x: 0, y: 0), end: CGPoint(x: renderSize.width, y: renderSize.height), options: [] ) } } } // MARK: - Custom Theme Editor private struct CustomThemeEditorView: View { @Environment(\.dismiss) private var dismiss @State private var name: String @State private var style: CustomThemeStyle @State private var colors: [Color] @State private var appearance: AppearanceOption let onSave: (CustomTheme) -> Void let onDelete: (() -> Void)? private let maxColors = 4 init(initialTheme: CustomTheme?, onSave: @escaping (CustomTheme) -> Void, onDelete: (() -> Void)? = nil) { self.onSave = onSave self.onDelete = onDelete if let theme = initialTheme { _name = State(initialValue: theme.name) _style = State(initialValue: theme.style) let baseColors = theme.gradientColors _colors = State(initialValue: baseColors.isEmpty ? [Color.blue, Color.purple] : baseColors) _appearance = State(initialValue: AppearanceOption(theme.preferredColorScheme)) self.initialTheme = theme } else { _name = State(initialValue: "") _style = State(initialValue: .staticGradient) _colors = State(initialValue: [Color(hex: "#3E4C7C") ?? .indigo, Color(hex: "#1C1F3A") ?? .blue]) _appearance = State(initialValue: .system) self.initialTheme = nil } } private let initialTheme: CustomTheme? var body: some View { NavigationStack { Form { Section(header: Text("Details")) { TextField("Theme Name", text: $name) .textInputAutocapitalization(.words) Picker("Style", selection: $style) { ForEach(CustomThemeStyle.allCases) { style in Text(style.displayName).tag(style) } } Picker("Appearance", selection: $appearance) { ForEach(AppearanceOption.allCases) { option in Text(option.title).tag(option) } } .pickerStyle(.segmented) } Section(header: Text("Colors")) { ForEach(colors.indices, id: \.self) { index in HStack { ColorPicker("", selection: Binding(get: { colors[index] }, set: { newValue in if index < colors.count { colors[index] = newValue } }), supportsOpacity: false) .labelsHidden() if colors.count > 2 { Button(role: .destructive) { colors.remove(at: index) } label: { Image(systemName: "minus.circle") } .padding(.leading, 4) } } } if colors.count < maxColors { Button { colors.append(colors.last ?? .blue) } label: { Label("Add Color", systemImage: "plus.circle") } } } if let onDelete { Section { Button("Delete Theme", role: .destructive) { onDelete() dismiss() } } } } .navigationTitle(initialTheme == nil ? "New Theme" : "Edit Theme") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { let hexes = colors.compactMap { $0.toHex() ?? "#3E4C7C" } let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) let finalName = trimmed.isEmpty ? "Untitled Theme" : trimmed let theme = CustomTheme(id: initialTheme?.id ?? UUID(), name: finalName, style: style, colorHexes: hexes, preferredColorScheme: appearance.colorScheme) onSave(theme) dismiss() } .disabled(colors.count < 2 || colors.allSatisfy { $0.toHex() == nil }) } } } } private enum AppearanceOption: String, CaseIterable, Identifiable { case system case light case dark var id: String { rawValue } var title: String { switch self { case .system: return "System" case .light: return "Light" case .dark: return "Dark" } } var colorScheme: ColorScheme? { switch self { case .system: return nil case .light: return .light case .dark: return .dark } } init(_ scheme: ColorScheme?) { switch scheme { case .some(.light): self = .light case .some(.dark): self = .dark default: self = .system } } } }