From 4e4cb2b7821ed09a767c2f5474f365eb058e9c2e Mon Sep 17 00:00:00 2001 From: Stephen B <158498287+StephenDev0@users.noreply.github.com> Date: Sun, 7 Dec 2025 23:46:02 -0500 Subject: [PATCH] Eliminate lag on the themes page --- StikJIT/Views/DisplayView.swift | 1065 ++++++++++++++++++++----------- 1 file changed, 695 insertions(+), 370 deletions(-) diff --git a/StikJIT/Views/DisplayView.swift b/StikJIT/Views/DisplayView.swift index 370a439..c6dc11d 100644 --- a/StikJIT/Views/DisplayView.swift +++ b/StikJIT/Views/DisplayView.swift @@ -104,6 +104,13 @@ struct DisplayView: View { 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 } @@ -225,11 +232,12 @@ struct DisplayView: View { // MARK: - Cards private var usernameCard: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Username") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) + appGlassCard { + VStack(alignment: .leading, spacing: 12) { + Text("Username") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) HStack { TextField("Username", text: $username) @@ -254,193 +262,166 @@ struct DisplayView: View { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) ) + } } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } private var accentCard: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Accent") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) + appGlassCard { + VStack(alignment: .leading, spacing: 12) { + Text("Accent") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) - AccentColorPicker(selectedColor: $selectedAccentColor) + AccentColorPicker(selectedColor: $selectedAccentColor) - HStack(spacing: 12) { - Button { - if let hex = selectedAccentColor.toHex() { - customAccentColorHex = hex - } else { + 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)) + ) } - 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)) - ) } } } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } private var themeExpansionUpsellCard: some View { let isAppStore = themeExpansion?.isAppStoreBuild ?? true let productLoaded = themeExpansion?.themeExpansionProduct != nil - return VStack(alignment: .leading, spacing: 14) { - Text("StikDebug Theme Expansion") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) + 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) + 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 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") + if let price = themeExpansion?.themeExpansionProduct?.displayPrice { + Text("One-time purchase • \(price)") .font(.subheadline) - .fontWeight(.semibold) + .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, 10) + .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) } - .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 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 error = themeExpansion?.lastError { - Text(error) - .font(.footnote) - .foregroundColor(.red) + 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) + } } } } - .padding(20) - .frame(maxWidth: .infinity) // ensure background fills available width - .background( - // Slightly thicker material so the upsell stands out over the previews - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) .task { if let manager = themeExpansion, manager.isAppStoreBuild, @@ -453,157 +434,142 @@ struct DisplayView: View { } private var jitOptionsCard: some View { - 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) + appGlassCard { + VStack(alignment: .leading, spacing: 12) { + Text("App List") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) - 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) + 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) + } } } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } private var themeCard: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Theme") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - - // Grid of theme previews - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - ForEach(AppTheme.allCases, id: \.self) { theme in - let isSelected = selectedBuiltInTheme == theme && selectedCustomTheme == nil - ThemePreviewCard(style: theme.backgroundStyle, - title: theme.displayName, - selected: isSelected, - action: { - guard hasThemeExpansion else { return } - appThemeRaw = theme.rawValue - applyThemePreferences() - showSavedToast() - }, - staticPreview: !isSelected) - } + appGlassCard { + VStack(alignment: .leading, spacing: 16) { + Text("Themes") + .font(.title3.weight(.semibold)) + .foregroundColor(.primary) + + selectedThemePreview + Divider() + builtInThemesGrid(interactive: hasThemeExpansion, locked: !hasThemeExpansion) } } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } - // Static version used in locked preview to avoid background animation cost - private var themeCardStatic: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Theme") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - ForEach(AppTheme.allCases, id: \.self) { theme in - ThemePreviewCard(style: theme.backgroundStyle, - title: theme.displayName, - selected: selectedBuiltInTheme == theme && selectedCustomTheme == nil, - action: {}, - staticPreview: true) - } + 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) } } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + } + + 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 { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text("Custom Themes") - .font(.title3) - .fontWeight(.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") + 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)) - .padding(.vertical, 10) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.blue) - ) - .foregroundColor(Color.blue.contrastText()) } } - } else { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - ForEach(manager.customThemes, id: \.id) { theme in - let identifier = manager.customThemeIdentifier(for: theme) - let isSelected = selectedCustomTheme?.id == theme.id - ThemePreviewCard(style: manager.backgroundStyle(for: identifier), - title: theme.name, - selected: isSelected, - action: { - appThemeRaw = identifier - applyThemePreferences() - showSavedToast() - }, - staticPreview: !isSelected) - .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() + + 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() + } } } } @@ -611,16 +577,6 @@ struct DisplayView: View { } } } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } } @@ -685,59 +641,119 @@ struct DisplayView: View { // 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(themeCardStatic) } + private var themePreview: some View { lockedPreview(themeLockedPreviewCard) } private var customThemesPreview: some View { lockedPreview(customThemesPreviewCard) } private var customThemesPreviewCard: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text("Custom Themes") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - Spacer() - Label("New", systemImage: "plus.circle.fill") - .font(.subheadline.weight(.semibold)) - .opacity(0.6) - } - - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - ThemePreviewCard( - style: .customGradient(colors: [Color(hex: "#3E4C7C") ?? .indigo, - Color(hex: "#1C1F3A") ?? .blue]), - title: "Midnight Fade", - selected: false, - action: {}, - staticPreview: true - ) - ThemePreviewCard( - style: .customGradient(colors: [Color(hex: "#00F5A0") ?? .green, - Color(hex: "#00D9F5") ?? .cyan, - Color(hex: "#C96BFF") ?? .purple]), - title: "Neon Drift", - selected: false, - action: {}, - staticPreview: true - ) + 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: {}) + } } } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) } } -// MARK: - Theme Preview Card +// 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 @@ -745,8 +761,11 @@ private struct ThemePreviewCard: View { 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 { @@ -769,35 +788,341 @@ private struct ThemePreviewCard: View { } var body: some View { - Button(action: action) { - ZStack { - ThemedBackground(style: staticPreview ? staticized(style) : style) - .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()) + Group { + if allowsInteraction { + Button(action: action) { + cardBody } - .padding(8) + .buttonStyle(.plain) + } else { + cardBody } - .frame(height: 120) - .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) } - .buttonStyle(.plain) + } + + 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: [] + ) + } } }