Эх сурвалжийг харах

Add App Store rating prompts after subscription and Pro usage.

Show SKStoreReviewController ~4.5s after a successful purchase, and again after 30 minutes of continuous foreground use while subscribed.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 сар өмнө
parent
commit
05d3962ddb

+ 2 - 0
App for Indeed/AppDelegate.swift

@@ -53,6 +53,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     }
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {
+        AppRatingCoordinator.shared.start()
+
         NotificationCenter.default.addObserver(
             forName: NSApplication.didBecomeActiveNotification,
             object: nil,

+ 1 - 0
App for Indeed/Controllers/PremiumPlansWindowController.swift

@@ -963,6 +963,7 @@ private final class PremiumPlansViewController: NSViewController {
         do {
             let completed = try await subscriptionStore.purchase(planKey: planKey)
             guard completed else { return }
+            AppRatingCoordinator.shared.scheduleReviewAfterSubscriptionPurchase()
             let alert = NSAlert()
             alert.messageText = "You're subscribed"
             alert.informativeText = "Thank you — Pro features are now available."

+ 205 - 0
App for Indeed/Services/AppRatingCoordinator.swift

@@ -0,0 +1,205 @@
+//
+//  AppRatingCoordinator.swift
+//  App for Indeed
+//
+
+import AppKit
+import Foundation
+import StoreKit
+
+/// Prompts for an App Store rating after subscription purchase and after sustained Pro usage.
+@MainActor
+final class AppRatingCoordinator {
+    static let shared = AppRatingCoordinator()
+
+    private enum UserDefaultsKey {
+        static let ratingPromptShownAfterPurchase = "com.appforindeed.ratingPromptShownAfterPurchase"
+        static let ratingPromptShownAfterUsage = "com.appforindeed.ratingPromptShownAfterUsage"
+        static let premiumUsageAccumulatedSeconds = "com.appforindeed.premiumUsageAccumulatedSeconds"
+    }
+
+    private enum ReviewTrigger {
+        case afterPurchase
+        case afterUsageMilestone
+    }
+
+    /// Delay after a successful subscription purchase before showing the rating prompt.
+    private let postPurchaseDelay: TimeInterval = 4.5
+    private let usageMilestoneSeconds: TimeInterval = 30 * 60
+    private let usageCheckInterval: TimeInterval = 30
+
+    private var didInstallObservers = false
+    private var observers: [NSObjectProtocol] = []
+    private var premiumSessionStartedAt: Date?
+    private var usageCheckTimer: Timer?
+    private var postPurchaseWorkItem: DispatchWorkItem?
+
+    private var ratingPromptShownAfterPurchase: Bool {
+        get { UserDefaults.standard.bool(forKey: UserDefaultsKey.ratingPromptShownAfterPurchase) }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.ratingPromptShownAfterPurchase) }
+    }
+
+    private var ratingPromptShownAfterUsage: Bool {
+        get { UserDefaults.standard.bool(forKey: UserDefaultsKey.ratingPromptShownAfterUsage) }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.ratingPromptShownAfterUsage) }
+    }
+
+    private var premiumUsageAccumulatedSeconds: TimeInterval {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKey.premiumUsageAccumulatedSeconds) }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.premiumUsageAccumulatedSeconds) }
+    }
+
+    private init() {}
+
+    /// Call once at launch (e.g. from `AppDelegate`) to track Pro foreground usage.
+    func start() {
+        guard !didInstallObservers else { return }
+        didInstallObservers = true
+
+        let center = NotificationCenter.default
+        observers.append(
+            center.addObserver(
+                forName: NSApplication.didBecomeActiveNotification,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                Task { @MainActor in
+                    self?.handleDidBecomeActive()
+                }
+            }
+        )
+        observers.append(
+            center.addObserver(
+                forName: NSApplication.willResignActiveNotification,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                Task { @MainActor in
+                    self?.handleWillResignActive()
+                }
+            }
+        )
+        observers.append(
+            center.addObserver(
+                forName: .subscriptionStatusDidChange,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                Task { @MainActor in
+                    self?.handleSubscriptionStatusDidChange()
+                }
+            }
+        )
+
+        if NSApp.isActive, SubscriptionStore.shared.isProActive {
+            beginPremiumUsageSession()
+        }
+    }
+
+    /// Schedules the rating prompt ~4.5s after a successful new subscription purchase.
+    func scheduleReviewAfterSubscriptionPurchase() {
+        postPurchaseWorkItem?.cancel()
+        let work = DispatchWorkItem { [weak self] in
+            self?.requestReviewIfNeeded(trigger: .afterPurchase)
+        }
+        postPurchaseWorkItem = work
+        DispatchQueue.main.asyncAfter(deadline: .now() + postPurchaseDelay, execute: work)
+    }
+
+    // MARK: - Lifecycle
+
+    private func handleDidBecomeActive() {
+        guard SubscriptionStore.shared.isProActive else { return }
+        beginPremiumUsageSession()
+    }
+
+    private func handleWillResignActive() {
+        flushPremiumUsageSession()
+        stopUsageCheckTimer()
+    }
+
+    private func handleSubscriptionStatusDidChange() {
+        if SubscriptionStore.shared.isProActive {
+            if NSApp.isActive {
+                beginPremiumUsageSession()
+            }
+        } else {
+            postPurchaseWorkItem?.cancel()
+            postPurchaseWorkItem = nil
+            flushPremiumUsageSession()
+            stopUsageCheckTimer()
+            premiumUsageAccumulatedSeconds = 0
+        }
+    }
+
+    // MARK: - Usage tracking
+
+    private func beginPremiumUsageSession() {
+        guard SubscriptionStore.shared.isProActive else { return }
+        if premiumSessionStartedAt == nil {
+            premiumSessionStartedAt = Date()
+        }
+        startUsageCheckTimerIfNeeded()
+        evaluateUsageMilestone()
+    }
+
+    private func flushPremiumUsageSession() {
+        guard let startedAt = premiumSessionStartedAt else { return }
+        premiumUsageAccumulatedSeconds += Date().timeIntervalSince(startedAt)
+        premiumSessionStartedAt = nil
+    }
+
+    private func currentPremiumUsageSeconds() -> TimeInterval {
+        var total = premiumUsageAccumulatedSeconds
+        if let startedAt = premiumSessionStartedAt {
+            total += Date().timeIntervalSince(startedAt)
+        }
+        return total
+    }
+
+    private func startUsageCheckTimerIfNeeded() {
+        guard usageCheckTimer == nil else { return }
+        usageCheckTimer = Timer.scheduledTimer(withTimeInterval: usageCheckInterval, repeats: true) { [weak self] _ in
+            Task { @MainActor in
+                self?.evaluateUsageMilestone()
+            }
+        }
+    }
+
+    private func stopUsageCheckTimer() {
+        usageCheckTimer?.invalidate()
+        usageCheckTimer = nil
+    }
+
+    private func evaluateUsageMilestone() {
+        guard SubscriptionStore.shared.isProActive else { return }
+        guard !ratingPromptShownAfterUsage else { return }
+        guard currentPremiumUsageSeconds() >= usageMilestoneSeconds else { return }
+        requestReviewIfNeeded(trigger: .afterUsageMilestone)
+    }
+
+    // MARK: - Review prompt
+
+    private func requestReviewIfNeeded(trigger: ReviewTrigger) {
+        guard SubscriptionStore.shared.isProActive else { return }
+
+        switch trigger {
+        case .afterPurchase:
+            guard !ratingPromptShownAfterPurchase else { return }
+        case .afterUsageMilestone:
+            guard !ratingPromptShownAfterUsage else { return }
+        }
+
+        SKStoreReviewController.requestReview()
+
+        switch trigger {
+        case .afterPurchase:
+            ratingPromptShownAfterPurchase = true
+        case .afterUsageMilestone:
+            ratingPromptShownAfterUsage = true
+            flushPremiumUsageSession()
+            premiumUsageAccumulatedSeconds = 0
+            stopUsageCheckTimer()
+        }
+    }
+}