Prechádzať zdrojové kódy

Update premium subscription management and upgrade behavior.

Add in-app manage subscription UI with yearly-to-lifetime upgrade entry, improve entitlement state tracking for refunds/expiration, and keep premium paywall behavior consistent during plan changes.

Made-with: Cursor
huzaifahayat12 3 mesiacov pred
rodič
commit
f6d4bccffe

+ 93 - 11
google_apps/LauncherRootView.swift

@@ -1,6 +1,7 @@
 import SwiftUI
 import AppKit
 import UniformTypeIdentifiers
+import WebKit
 
 struct LauncherRootView: View {
     enum LayoutMode {
@@ -12,7 +13,9 @@ struct LauncherRootView: View {
     @State private var query = ""
     @State private var selectedApp: LauncherApp?
     @State private var showingPremiumScreen = false
+    @State private var showingManageSubscriptionSheet = false
     @State private var showingCreateAppSheet = false
+    @State private var previouslyPremiumUnlocked = false
     @State private var customApps: [LauncherApp] = []
     @State private var appOrder: [UUID] = []
     @State private var draggedAppID: UUID?
@@ -115,14 +118,18 @@ struct LauncherRootView: View {
                 )
                 .padding(.top, 12)
 
-                if !premiumStore.isPremiumUnlocked {
-                    PromoBanner(
-                        onCloseTap: { showingPremiumScreen = true },
-                        onUpgradeTap: { showingPremiumScreen = true }
-                    )
-                    .padding(.bottom, 8)
-                    .zIndex(1)
-                }
+                PromoBanner(
+                    isPremiumUnlocked: premiumStore.isPremiumUnlocked,
+                    onCloseTap: {
+                        if !premiumStore.isPremiumUnlocked {
+                            showingPremiumScreen = true
+                        }
+                    },
+                    onUpgradeTap: { showingPremiumScreen = true },
+                    onManageSubscriptionTap: { showingManageSubscriptionSheet = true }
+                )
+                .padding(.bottom, 8)
+                .zIndex(1)
 
                 appsPage
             }
@@ -144,6 +151,16 @@ struct LauncherRootView: View {
             }
             .frame(minWidth: 760, minHeight: 420)
         }
+        .sheet(isPresented: $showingManageSubscriptionSheet) {
+            ManageSubscriptionSheet(
+                activePremiumProductID: premiumStore.activePremiumProductID,
+                onUpgradeTap: {
+                    showingManageSubscriptionSheet = false
+                    showingPremiumScreen = true
+                }
+            )
+                .frame(minWidth: 900, minHeight: 640)
+        }
         .task {
             await premiumStore.refreshEntitlements()
         }
@@ -153,7 +170,14 @@ struct LauncherRootView: View {
                 await premiumStore.refreshEntitlements()
             }
         }
+        .onChange(of: premiumStore.isPremiumUnlocked) { unlocked in
+            if previouslyPremiumUnlocked && !unlocked {
+                showingPremiumScreen = true
+            }
+            previouslyPremiumUnlocked = unlocked
+        }
         .onAppear {
+            previouslyPremiumUnlocked = premiumStore.isPremiumUnlocked
             loadCustomAppsFromStorage()
             loadAppOrderFromStorage()
             normalizeAppOrderAndPersist()
@@ -790,8 +814,10 @@ private func encodeUUIDSetGlobal(_ set: Set<UUID>) -> String {
 }
 
 private struct PromoBanner: View {
+    let isPremiumUnlocked: Bool
     let onCloseTap: () -> Void
     let onUpgradeTap: () -> Void
+    let onManageSubscriptionTap: () -> Void
 
     var body: some View {
         HStack(spacing: 10) {
@@ -802,15 +828,15 @@ private struct PromoBanner: View {
             }
             .buttonStyle(.plain)
 
-            Text("Update to Premium to unlock all features and remove ads")
+            Text(isPremiumUnlocked ? "Premium is active. Manage your subscription any time." : "Update to Premium to unlock all features and remove ads")
                 .font(.system(size: 13, weight: .medium))
                 .foregroundStyle(.blue.opacity(0.95))
                 .lineLimit(1)
 
             Spacer()
 
-            Button(action: onUpgradeTap) {
-                Text("Upgrade")
+            Button(action: isPremiumUnlocked ? onManageSubscriptionTap : onUpgradeTap) {
+                Text(isPremiumUnlocked ? "Manage Subscription" : "Upgrade")
                     .font(.system(size: 13, weight: .semibold))
                     .foregroundStyle(.white)
                     .padding(.horizontal, 12)
@@ -835,6 +861,62 @@ private struct PromoBanner: View {
     }
 }
 
+private struct ManageSubscriptionSheet: View {
+    @Environment(\.dismiss) private var dismiss
+    let activePremiumProductID: PremiumProductID?
+    let onUpgradeTap: () -> Void
+    private let url = URL(string: "https://apps.apple.com/account/subscriptions")!
+
+    var body: some View {
+        VStack(spacing: 0) {
+            HStack {
+                Text("Manage Subscription")
+                    .font(.system(size: 15, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.92))
+                Spacer()
+                Button("Done") { dismiss() }
+                    .buttonStyle(.borderedProminent)
+                    .controlSize(.small)
+            }
+            .padding(12)
+            .background(Color.black.opacity(0.25))
+
+            if activePremiumProductID == .yearly {
+                HStack(spacing: 10) {
+                    Text("You are on the yearly plan. Upgrade to Lifetime for permanent access.")
+                        .font(.system(size: 12.5, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.9))
+                    Spacer()
+                    Button("Upgrade Package") {
+                        onUpgradeTap()
+                    }
+                    .buttonStyle(.borderedProminent)
+                    .controlSize(.small)
+                }
+                .padding(.horizontal, 12)
+                .padding(.vertical, 10)
+                .background(Color.blue.opacity(0.18))
+            }
+
+            SubscriptionWebView(url: url)
+        }
+        .background(Color.black.opacity(0.3))
+    }
+}
+
+private struct SubscriptionWebView: NSViewRepresentable {
+    let url: URL
+
+    func makeNSView(context: Context) -> WKWebView {
+        let webView = WKWebView()
+        webView.allowsBackForwardNavigationGestures = true
+        webView.load(URLRequest(url: url))
+        return webView
+    }
+
+    func updateNSView(_ webView: WKWebView, context: Context) {}
+}
+
 private struct HideMenuIndicatorIfAvailable: ViewModifier {
     func body(content: Content) -> some View {
         if #available(macOS 13.0, *) {

+ 0 - 10
google_apps/PremiumFeaturesView.swift

@@ -232,16 +232,6 @@ struct PremiumFeaturesView: View {
         .task {
             await premiumStore.loadProducts()
         }
-        .onAppear {
-            if premiumStore.isPremiumUnlocked {
-                dismiss()
-            }
-        }
-        .onChange(of: premiumStore.isPremiumUnlocked) { unlocked in
-            if unlocked {
-                dismiss()
-            }
-        }
         .alert("Payment Successful", isPresented: $showingSuccessAlert) {
             Button("Done") { dismiss() }
         } message: {

+ 58 - 18
google_apps/PremiumStore.swift

@@ -11,10 +11,10 @@ enum PremiumProductID: String, CaseIterable {
 @MainActor
 final class PremiumStore: ObservableObject {
     static let shared = PremiumStore()
-    private static let localPremiumUnlockKey = "premiumUnlockedLocal"
 
     @Published private(set) var products: [Product] = []
     @Published private(set) var isPremiumUnlocked = false
+    @Published private(set) var activePremiumProductID: PremiumProductID?
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var loadError: String?
     @Published private(set) var purchaseInProgress = false
@@ -23,8 +23,6 @@ final class PremiumStore: ObservableObject {
     private var transactionListener: Task<Void, Never>?
 
     private init() {
-        // Keep UI in sync immediately after a successful purchase.
-        isPremiumUnlocked = UserDefaults.standard.bool(forKey: Self.localPremiumUnlockKey)
         transactionListener = Task { await listenForTransactions() }
         Task { await refreshEntitlements() }
     }
@@ -81,7 +79,6 @@ final class PremiumStore: ObservableObject {
                 case .verified(let transaction):
                     if PremiumProductID(rawValue: transaction.productID) != nil {
                         isPremiumUnlocked = true
-                        UserDefaults.standard.set(true, forKey: Self.localPremiumUnlockKey)
                     }
                     await transaction.finish()
                     await refreshEntitlements()
@@ -115,30 +112,73 @@ final class PremiumStore: ObservableObject {
     }
 
     func refreshEntitlements() async {
+        let state = await resolvePremiumState()
+        isPremiumUnlocked = state.isUnlocked
+        activePremiumProductID = state.activeProductID
+    }
+
+    private func listenForTransactions() async {
+        for await result in Transaction.updates {
+            guard case .verified(let transaction) = result else { continue }
+            await transaction.finish()
+            await refreshEntitlements()
+        }
+    }
+
+    private func resolvePremiumState() async -> (isUnlocked: Bool, activeProductID: PremiumProductID?) {
         let validIDs = Set(PremiumProductID.allCases.map(\.rawValue))
-        var unlocked = false
+        var found: [PremiumProductID] = []
 
         for await result in Transaction.currentEntitlements {
             guard case .verified(let transaction) = result else { continue }
-            if validIDs.contains(transaction.productID) {
-                unlocked = true
-                break
+            guard validIDs.contains(transaction.productID) else { continue }
+            if isTransactionActive(transaction) {
+                if let productID = PremiumProductID(rawValue: transaction.productID) {
+                    found.append(productID)
+                }
+            }
+        }
+
+        if let preferred = preferredActiveProductID(from: found) {
+            return (true, preferred)
+        }
+
+        // Fallback path for cases where `currentEntitlements` is briefly empty.
+        found.removeAll()
+        for id in PremiumProductID.allCases.map(\.rawValue) {
+            guard let latestResult = await Transaction.latest(for: id) else { continue }
+            guard case .verified(let transaction) = latestResult else { continue }
+            if isTransactionActive(transaction) {
+                if let productID = PremiumProductID(rawValue: transaction.productID) {
+                    found.append(productID)
+                }
             }
         }
 
-        let cachedUnlock = UserDefaults.standard.bool(forKey: Self.localPremiumUnlockKey)
-        let finalUnlock = unlocked || cachedUnlock
-        isPremiumUnlocked = finalUnlock
-        if finalUnlock {
-            UserDefaults.standard.set(true, forKey: Self.localPremiumUnlockKey)
+        if let preferred = preferredActiveProductID(from: found) {
+            return (true, preferred)
         }
+
+        return (false, nil)
     }
 
-    private func listenForTransactions() async {
-        for await result in Transaction.updates {
-            guard case .verified(let transaction) = result else { continue }
-            await transaction.finish()
-            await refreshEntitlements()
+    private func isTransactionActive(_ transaction: Transaction) -> Bool {
+        if transaction.revocationDate != nil {
+            return false
+        }
+        if let expirationDate = transaction.expirationDate, expirationDate <= Date() {
+            return false
+        }
+        return true
+    }
+
+    private func preferredActiveProductID(from ids: [PremiumProductID]) -> PremiumProductID? {
+        if ids.contains(.perpetual) {
+            return .perpetual
+        }
+        if ids.contains(.yearly) {
+            return .yearly
         }
+        return nil
     }
 }