Browse Source

Add premium app rating prompts on first purchase and after 15 minutes of use.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 tháng trước cách đây
mục cha
commit
9b6d84fe12

+ 1 - 0
smart_printer/AppDelegate.swift

@@ -17,6 +17,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
 
     func applicationDidFinishLaunching(_ notification: Notification) {
         StoreManager.shared.start()
+        AppRatingManager.shared.start()
         resolveMainWindowController()
         configureMainWindow()
         configurePreferencesMenu()

+ 341 - 0
smart_printer/AppRatingManager.swift

@@ -0,0 +1,341 @@
+import Cocoa
+import StoreKit
+
+@MainActor
+final class AppRatingManager {
+    static let shared = AppRatingManager()
+
+    private enum Constants {
+        static let usageThresholdSeconds: TimeInterval = 15 * 60
+        static let usageCheckInterval: TimeInterval = 30
+        static let promptPresentationDelay: TimeInterval = 1.5
+    }
+
+    private enum PromptSource {
+        case firstPurchase
+        case usageMilestone
+
+        var title: String {
+            switch self {
+            case .firstPurchase:
+                "Enjoying Smart Printer?"
+            case .usageMilestone:
+                "Enjoying Smart Printer?"
+            }
+        }
+
+        var message: String {
+            switch self {
+            case .firstPurchase:
+                "Thanks for going Premium! Do you like the app?"
+            case .usageMilestone:
+                "You've been using Smart Printer for a while. Would you mind rating us on the App Store?"
+            }
+        }
+    }
+
+    private struct State {
+        var firstPurchasePromptPending = false
+        var firstPurchasePromptCompleted = false
+        var usageBasedPromptCompleted = false
+        var hasRequestedReview = false
+        var accumulatedActiveUsageSeconds: TimeInterval = 0
+
+        static func load() -> State {
+            let defaults = UserDefaults.standard
+            var state = State()
+
+            if defaults.object(forKey: Key.accumulatedActiveUsageSeconds) != nil {
+                state.accumulatedActiveUsageSeconds = defaults.double(forKey: Key.accumulatedActiveUsageSeconds)
+            }
+
+            state.firstPurchasePromptPending = defaults.bool(forKey: Key.firstPurchasePromptPending)
+            state.firstPurchasePromptCompleted = defaults.bool(forKey: Key.firstPurchasePromptCompleted)
+            state.usageBasedPromptCompleted = defaults.bool(forKey: Key.usageBasedPromptCompleted)
+            state.hasRequestedReview = defaults.bool(forKey: Key.hasRequestedReview)
+
+            if defaults.bool(forKey: Key.legacyHasPromptedAfterFirstPurchase) {
+                state.firstPurchasePromptCompleted = true
+                defaults.removeObject(forKey: Key.legacyHasPromptedAfterFirstPurchase)
+            }
+
+            defaults.removeObject(forKey: Key.legacyPromptedTransactionIDs)
+            return state
+        }
+
+        func save() {
+            let defaults = UserDefaults.standard
+            defaults.set(firstPurchasePromptPending, forKey: Key.firstPurchasePromptPending)
+            defaults.set(firstPurchasePromptCompleted, forKey: Key.firstPurchasePromptCompleted)
+            defaults.set(usageBasedPromptCompleted, forKey: Key.usageBasedPromptCompleted)
+            defaults.set(hasRequestedReview, forKey: Key.hasRequestedReview)
+            defaults.set(accumulatedActiveUsageSeconds, forKey: Key.accumulatedActiveUsageSeconds)
+        }
+    }
+
+    private enum Key {
+        static let firstPurchasePromptPending = "appRating.firstPurchasePromptPending"
+        static let firstPurchasePromptCompleted = "appRating.firstPurchasePromptCompleted"
+        static let usageBasedPromptCompleted = "appRating.usageBasedPromptCompleted"
+        static let hasRequestedReview = "appRating.hasRequestedReview"
+        static let accumulatedActiveUsageSeconds = "appRating.accumulatedActiveUsageSeconds"
+        static let legacyHasPromptedAfterFirstPurchase = "appRating.hasPromptedAfterFirstPurchase"
+        static let legacyPromptedTransactionIDs = "appRating.promptedTransactionIDs"
+    }
+
+    private var state = State.load()
+    private var activeSessionStart: Date?
+    private var usageCheckTimer: Timer?
+    private var isShowingPrompt = false
+    private var pendingPromptWorkItem: DispatchWorkItem?
+    private var observers: [NSObjectProtocol] = []
+    private var hasStarted = false
+
+    private init() {}
+
+    func start() {
+        guard !hasStarted else { return }
+        hasStarted = true
+
+        let center = NotificationCenter.default
+        observers.append(
+            center.addObserver(
+                forName: NSApplication.didBecomeActiveNotification,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                Task { @MainActor in self?.applicationDidBecomeActive() }
+            }
+        )
+        observers.append(
+            center.addObserver(
+                forName: NSApplication.willResignActiveNotification,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                Task { @MainActor in self?.applicationWillResignActive() }
+            }
+        )
+        observers.append(
+            center.addObserver(
+                forName: .premiumStatusDidChange,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                Task { @MainActor in self?.premiumStatusDidChange() }
+            }
+        )
+
+        if NSApp.isActive {
+            applicationDidBecomeActive()
+        }
+    }
+
+    /// Asks premium users on their first purchase whether they like the app.
+    func promptAfterFirstPurchaseIfNeeded(on window: NSWindow?) {
+        guard isEligibleForAnyPrompt else { return }
+        guard canShowFirstPurchasePrompt else { return }
+
+        state.firstPurchasePromptPending = true
+        state.save()
+        presentPrompt(source: .firstPurchase, on: window ?? presentationWindow())
+    }
+
+    // MARK: - App lifecycle
+
+    private func applicationDidBecomeActive() {
+        resumeUsageTrackingIfNeeded()
+        scheduleDeferredPromptChecks()
+    }
+
+    private func applicationWillResignActive() {
+        commitActiveUsageSession()
+        stopUsageCheckTimer()
+        pendingPromptWorkItem?.cancel()
+        pendingPromptWorkItem = nil
+    }
+
+    private func premiumStatusDidChange() {
+        if StoreManager.shared.isPremium {
+            resumeUsageTrackingIfNeeded()
+            scheduleDeferredPromptChecks()
+        } else {
+            commitActiveUsageSession()
+            stopUsageCheckTimer()
+        }
+    }
+
+    // MARK: - Eligibility
+
+    private var isEligibleForAnyPrompt: Bool {
+        StoreManager.shared.isPremium && !state.hasRequestedReview
+    }
+
+    private var canShowFirstPurchasePrompt: Bool {
+        isEligibleForAnyPrompt && !state.firstPurchasePromptCompleted
+    }
+
+    private var canShowUsageBasedPrompt: Bool {
+        isEligibleForAnyPrompt
+            && state.firstPurchasePromptCompleted
+            && !state.usageBasedPromptCompleted
+            && currentAccumulatedUsageSeconds >= Constants.usageThresholdSeconds
+    }
+
+    private var shouldTrackUsage: Bool {
+        isEligibleForAnyPrompt
+            && state.firstPurchasePromptCompleted
+            && !state.usageBasedPromptCompleted
+    }
+
+    private var currentAccumulatedUsageSeconds: TimeInterval {
+        var total = state.accumulatedActiveUsageSeconds
+        if let activeSessionStart {
+            total += Date().timeIntervalSince(activeSessionStart)
+        }
+        return total
+    }
+
+    // MARK: - Usage tracking
+
+    private func resumeUsageTrackingIfNeeded() {
+        guard shouldTrackUsage else {
+            commitActiveUsageSession()
+            stopUsageCheckTimer()
+            return
+        }
+
+        if activeSessionStart == nil {
+            activeSessionStart = Date()
+        }
+        startUsageCheckTimer()
+    }
+
+    private func commitActiveUsageSession() {
+        guard let sessionStart = activeSessionStart else { return }
+        let elapsed = max(0, Date().timeIntervalSince(sessionStart))
+        activeSessionStart = nil
+        guard elapsed > 0 else { return }
+
+        state.accumulatedActiveUsageSeconds += elapsed
+        state.save()
+    }
+
+    private func startUsageCheckTimer() {
+        guard usageCheckTimer == nil else { return }
+
+        let timer = Timer(timeInterval: Constants.usageCheckInterval, repeats: true) { [weak self] _ in
+            Task { @MainActor in
+                self?.evaluateUsageMilestonePrompt()
+            }
+        }
+        RunLoop.main.add(timer, forMode: .common)
+        usageCheckTimer = timer
+    }
+
+    private func stopUsageCheckTimer() {
+        usageCheckTimer?.invalidate()
+        usageCheckTimer = nil
+    }
+
+    private func evaluateUsageMilestonePrompt() {
+        guard shouldTrackUsage else {
+            stopUsageCheckTimer()
+            return
+        }
+
+        guard currentAccumulatedUsageSeconds >= Constants.usageThresholdSeconds else { return }
+        presentPrompt(source: .usageMilestone, on: presentationWindow())
+    }
+
+    // MARK: - Prompt presentation
+
+    private func scheduleDeferredPromptChecks() {
+        pendingPromptWorkItem?.cancel()
+
+        let workItem = DispatchWorkItem { [weak self] in
+            guard let self else { return }
+            if self.canShowFirstPurchasePrompt, self.state.firstPurchasePromptPending {
+                self.presentPrompt(source: .firstPurchase, on: self.presentationWindow())
+                return
+            }
+            self.evaluateUsageMilestonePrompt()
+        }
+
+        pendingPromptWorkItem = workItem
+        DispatchQueue.main.asyncAfter(deadline: .now() + Constants.promptPresentationDelay, execute: workItem)
+    }
+
+    private func presentPrompt(source: PromptSource, on window: NSWindow?) {
+        guard !isShowingPrompt else { return }
+
+        switch source {
+        case .firstPurchase:
+            guard canShowFirstPurchasePrompt else { return }
+        case .usageMilestone:
+            guard canShowUsageBasedPrompt else { return }
+            commitActiveUsageSession()
+            stopUsageCheckTimer()
+        }
+
+        isShowingPrompt = true
+
+        let alert = NSAlert()
+        alert.messageText = source.title
+        alert.informativeText = source.message
+        alert.alertStyle = .informational
+        alert.addButton(withTitle: "Yes")
+        alert.addButton(withTitle: "Not Really")
+
+        let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak self] response in
+            self?.handlePromptResponse(source: source, response: response, window: window)
+        }
+
+        if let window {
+            alert.beginSheetModal(for: window) { response in
+                handleResponse(response)
+            }
+        } else {
+            handleResponse(alert.runModal())
+        }
+    }
+
+    private func handlePromptResponse(
+        source: PromptSource,
+        response: NSApplication.ModalResponse,
+        window: NSWindow?
+    ) {
+        isShowingPrompt = false
+
+        switch source {
+        case .firstPurchase:
+            state.firstPurchasePromptPending = false
+            state.firstPurchasePromptCompleted = true
+        case .usageMilestone:
+            state.usageBasedPromptCompleted = true
+            stopUsageCheckTimer()
+        }
+
+        if response == .alertFirstButtonReturn {
+            state.hasRequestedReview = true
+            state.save()
+            requestAppStoreReview(from: window)
+            return
+        }
+
+        state.save()
+        resumeUsageTrackingIfNeeded()
+    }
+
+    private func requestAppStoreReview(from window: NSWindow?) {
+        let viewController = window?.contentViewController
+            ?? presentationWindow()?.contentViewController
+            ?? NSApp.keyWindow?.contentViewController
+        guard let viewController else { return }
+        AppStore.requestReview(in: viewController)
+    }
+
+    private func presentationWindow() -> NSWindow? {
+        NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first(where: \.canBecomeMain)
+    }
+}

+ 1 - 0
smart_printer/PaywallView.swift

@@ -1232,6 +1232,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
                 refreshStoreState()
                 if succeeded {
                     onPurchaseSucceeded?()
+                    AppRatingManager.shared.promptAfterFirstPurchaseIfNeeded(on: window)
                 }
             } catch {
                 refreshPurchaseState()