2.3.2 - Part 1

This commit is contained in:
Stephen B 2025-09-25 13:20:41 -04:00
parent 9fd2f51871
commit 3fb8a8e956
3 changed files with 310 additions and 94 deletions

View file

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

View file

@ -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<Void, Never>?
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<StoreKit.Transaction>, 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

View file

@ -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: View>(_ 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 {
}
}
}