From 3fb8a8e95647d6b1f33475926e1ae6d1c85412ff Mon Sep 17 00:00:00 2001 From: Stephen B <158498287+StephenDev0@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:20:41 -0400 Subject: [PATCH] 2.3.2 - Part 1 --- StikDebug.xcodeproj/project.pbxproj | 4 +- StikJIT/Utilities/ThemeExpansionManager.swift | 65 +++- StikJIT/Views/DisplayView.swift | 335 +++++++++++++----- 3 files changed, 310 insertions(+), 94 deletions(-) diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 60caeaa..6e3d8a5 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -733,7 +733,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.3.1; + MARKETING_VERSION = 2.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -786,7 +786,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.3.1; + MARKETING_VERSION = 2.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; diff --git a/StikJIT/Utilities/ThemeExpansionManager.swift b/StikJIT/Utilities/ThemeExpansionManager.swift index 6350485..e7dbb0a 100644 --- a/StikJIT/Utilities/ThemeExpansionManager.swift +++ b/StikJIT/Utilities/ThemeExpansionManager.swift @@ -63,6 +63,14 @@ struct CustomTheme: Identifiable, Codable, Equatable { } } +// MARK: - Distribution detection (receipt-based) + +enum DistributorType: String { + case appStore + case testFlight + case other +} + @MainActor final class ThemeExpansionManager: ObservableObject { static let productIdentifier = "SD_Theme_Expansion" @@ -73,12 +81,17 @@ final class ThemeExpansionManager: ObservableObject { @Published var lastError: String? @Published private(set) var customThemes: [CustomTheme] = [] + // New: distribution awareness + @Published private(set) var distributor: DistributorType + var isAppStoreBuild: Bool { distributor == .appStore } + private var updatesTask: Task? private let isPreviewInstance: Bool private let customThemesKey = "ThemeExpansion.CustomThemes" init(previewUnlocked: Bool = false) { self.isPreviewInstance = previewUnlocked + self.distributor = ThemeExpansionManager.detectDistributor() self.hasThemeExpansion = previewUnlocked loadCustomThemes() @@ -93,23 +106,34 @@ final class ThemeExpansionManager: ObservableObject { guard !previewUnlocked else { return } - // Listen for transaction updates and apply verified ones - updatesTask = Task { [weak self] in - guard let self else { return } - for await result in StoreKit.Transaction.updates { - await self.handle(transactionResult: result) + // Only wire StoreKit listeners if this is an App Store build + if isAppStoreBuild { + updatesTask = Task { [weak self] in + guard let self else { return } + for await result in StoreKit.Transaction.updates { + await self.handle(transactionResult: result) + } } - } - Task { await refreshEntitlements() } + Task { await refreshEntitlements() } + } else { + // Non–App Store builds cannot purchase yet; keep everything locked and quiet + self.hasThemeExpansion = false + self.themeExpansionProduct = nil + self.lastError = nil + } } deinit { updatesTask?.cancel() } + // MARK: - Public API + func refreshEntitlements() async { guard !isPreviewInstance else { return } + guard isAppStoreBuild else { return } // No-op outside App Store + isProcessing = true defer { isProcessing = false } do { @@ -138,6 +162,10 @@ final class ThemeExpansionManager: ObservableObject { func restorePurchases() async { guard !isPreviewInstance else { return } + guard isAppStoreBuild else { + lastError = "Theme Expansion is coming soon on this store." + return + } isProcessing = true defer { isProcessing = false } do { @@ -151,6 +179,10 @@ final class ThemeExpansionManager: ObservableObject { func purchaseThemeExpansion() async { guard !isPreviewInstance else { return } + guard isAppStoreBuild else { + lastError = "Theme Expansion is coming soon on this store." + return + } let product: Product if let cached = themeExpansionProduct { @@ -277,6 +309,8 @@ final class ThemeExpansionManager: ObservableObject { saveCustomThemes() } + // MARK: - Persistence + private func loadCustomThemes() { guard let data = UserDefaults.standard.data(forKey: customThemesKey), let decoded = try? JSONDecoder().decode([CustomTheme].self, from: data) else { @@ -306,6 +340,8 @@ final class ThemeExpansionManager: ObservableObject { return [Color.blue, Color.purple] } + // MARK: - StoreKit plumbing + private func handle(transactionResult: VerificationResult, finishTransaction: Bool = true) async { switch transactionResult { case .verified(let transaction): @@ -329,6 +365,21 @@ final class ThemeExpansionManager: ObservableObject { } return false } + + // MARK: - Distributor detection helper + + private static func detectDistributor() -> DistributorType { + guard let receiptURL = Bundle.main.appStoreReceiptURL else { + return .other + } + let path = receiptURL.path + if FileManager.default.fileExists(atPath: path) { + // TestFlight builds use "sandboxReceipt" + return receiptURL.lastPathComponent == "sandboxReceipt" ? .testFlight : .appStore + } else { + return .other + } + } } #if DEBUG diff --git a/StikJIT/Views/DisplayView.swift b/StikJIT/Views/DisplayView.swift index 882e6f6..2b82c7e 100644 --- a/StikJIT/Views/DisplayView.swift +++ b/StikJIT/Views/DisplayView.swift @@ -122,7 +122,21 @@ struct DisplayView: View { themeCard customThemesSection } else { - themeExpansionUpsellCard + // 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 } @@ -313,6 +327,7 @@ struct DisplayView: View { } 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") @@ -320,92 +335,103 @@ struct DisplayView: View { .fontWeight(.semibold) .foregroundColor(.primary) - 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(.ultraThinMaterial) + .fill(.regularMaterial) .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) @@ -414,6 +440,7 @@ struct DisplayView: View { .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) .task { if let manager = themeExpansion, + manager.isAppStoreBuild, !manager.isProcessing, manager.themeExpansionProduct == nil, manager.lastError == nil { @@ -461,13 +488,15 @@ struct DisplayView: View { 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) { - guard hasThemeExpansion else { return } - appThemeRaw = theme.rawValue - applyThemePreferences() - showSavedToast() - } + title: theme.displayName, + selected: selectedBuiltInTheme == theme && selectedCustomTheme == nil, + action: { + guard hasThemeExpansion else { return } + appThemeRaw = theme.rawValue + applyThemePreferences() + showSavedToast() + }, + staticPreview: false) } } } @@ -480,7 +509,37 @@ struct DisplayView: View { .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) ) ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + .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) + } + } + } + .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) } @ViewBuilder @@ -525,11 +584,13 @@ struct DisplayView: View { let identifier = manager.customThemeIdentifier(for: theme) ThemePreviewCard(style: manager.backgroundStyle(for: identifier), title: theme.name, - selected: selectedCustomTheme?.id == theme.id) { - appThemeRaw = identifier - applyThemePreferences() - showSavedToast() - } + selected: selectedCustomTheme?.id == theme.id, + action: { + appThemeRaw = identifier + applyThemePreferences() + showSavedToast() + }, + staticPreview: false) .contextMenu { Button("Edit") { editingCustomTheme = theme } Button("Delete", role: .destructive) { @@ -584,6 +645,91 @@ struct DisplayView: View { 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(themeCardStatic) } + + 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 + ) + } + } + .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 @@ -593,11 +739,29 @@ private struct ThemePreviewCard: View { let title: String let selected: Bool let action: () -> Void + var staticPreview: Bool = false + + 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) + } + } var body: some View { Button(action: action) { ZStack { - ThemedBackground(style: style) + ThemedBackground(style: staticPreview ? staticized(style) : style) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(.ultraThinMaterial) @@ -786,3 +950,4 @@ private struct CustomThemeEditorView: View { } } } +