Ver código fonte

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 3 semanas atrás
pai
commit
05d3962ddb

+ 2 - 0
App for Indeed/AppDelegate.swift

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

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

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

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

@@ -0,0 +1,205 @@
1
+//
2
+//  AppRatingCoordinator.swift
3
+//  App for Indeed
4
+//
5
+
6
+import AppKit
7
+import Foundation
8
+import StoreKit
9
+
10
+/// Prompts for an App Store rating after subscription purchase and after sustained Pro usage.
11
+@MainActor
12
+final class AppRatingCoordinator {
13
+    static let shared = AppRatingCoordinator()
14
+
15
+    private enum UserDefaultsKey {
16
+        static let ratingPromptShownAfterPurchase = "com.appforindeed.ratingPromptShownAfterPurchase"
17
+        static let ratingPromptShownAfterUsage = "com.appforindeed.ratingPromptShownAfterUsage"
18
+        static let premiumUsageAccumulatedSeconds = "com.appforindeed.premiumUsageAccumulatedSeconds"
19
+    }
20
+
21
+    private enum ReviewTrigger {
22
+        case afterPurchase
23
+        case afterUsageMilestone
24
+    }
25
+
26
+    /// Delay after a successful subscription purchase before showing the rating prompt.
27
+    private let postPurchaseDelay: TimeInterval = 4.5
28
+    private let usageMilestoneSeconds: TimeInterval = 30 * 60
29
+    private let usageCheckInterval: TimeInterval = 30
30
+
31
+    private var didInstallObservers = false
32
+    private var observers: [NSObjectProtocol] = []
33
+    private var premiumSessionStartedAt: Date?
34
+    private var usageCheckTimer: Timer?
35
+    private var postPurchaseWorkItem: DispatchWorkItem?
36
+
37
+    private var ratingPromptShownAfterPurchase: Bool {
38
+        get { UserDefaults.standard.bool(forKey: UserDefaultsKey.ratingPromptShownAfterPurchase) }
39
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.ratingPromptShownAfterPurchase) }
40
+    }
41
+
42
+    private var ratingPromptShownAfterUsage: Bool {
43
+        get { UserDefaults.standard.bool(forKey: UserDefaultsKey.ratingPromptShownAfterUsage) }
44
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.ratingPromptShownAfterUsage) }
45
+    }
46
+
47
+    private var premiumUsageAccumulatedSeconds: TimeInterval {
48
+        get { UserDefaults.standard.double(forKey: UserDefaultsKey.premiumUsageAccumulatedSeconds) }
49
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.premiumUsageAccumulatedSeconds) }
50
+    }
51
+
52
+    private init() {}
53
+
54
+    /// Call once at launch (e.g. from `AppDelegate`) to track Pro foreground usage.
55
+    func start() {
56
+        guard !didInstallObservers else { return }
57
+        didInstallObservers = true
58
+
59
+        let center = NotificationCenter.default
60
+        observers.append(
61
+            center.addObserver(
62
+                forName: NSApplication.didBecomeActiveNotification,
63
+                object: nil,
64
+                queue: .main
65
+            ) { [weak self] _ in
66
+                Task { @MainActor in
67
+                    self?.handleDidBecomeActive()
68
+                }
69
+            }
70
+        )
71
+        observers.append(
72
+            center.addObserver(
73
+                forName: NSApplication.willResignActiveNotification,
74
+                object: nil,
75
+                queue: .main
76
+            ) { [weak self] _ in
77
+                Task { @MainActor in
78
+                    self?.handleWillResignActive()
79
+                }
80
+            }
81
+        )
82
+        observers.append(
83
+            center.addObserver(
84
+                forName: .subscriptionStatusDidChange,
85
+                object: nil,
86
+                queue: .main
87
+            ) { [weak self] _ in
88
+                Task { @MainActor in
89
+                    self?.handleSubscriptionStatusDidChange()
90
+                }
91
+            }
92
+        )
93
+
94
+        if NSApp.isActive, SubscriptionStore.shared.isProActive {
95
+            beginPremiumUsageSession()
96
+        }
97
+    }
98
+
99
+    /// Schedules the rating prompt ~4.5s after a successful new subscription purchase.
100
+    func scheduleReviewAfterSubscriptionPurchase() {
101
+        postPurchaseWorkItem?.cancel()
102
+        let work = DispatchWorkItem { [weak self] in
103
+            self?.requestReviewIfNeeded(trigger: .afterPurchase)
104
+        }
105
+        postPurchaseWorkItem = work
106
+        DispatchQueue.main.asyncAfter(deadline: .now() + postPurchaseDelay, execute: work)
107
+    }
108
+
109
+    // MARK: - Lifecycle
110
+
111
+    private func handleDidBecomeActive() {
112
+        guard SubscriptionStore.shared.isProActive else { return }
113
+        beginPremiumUsageSession()
114
+    }
115
+
116
+    private func handleWillResignActive() {
117
+        flushPremiumUsageSession()
118
+        stopUsageCheckTimer()
119
+    }
120
+
121
+    private func handleSubscriptionStatusDidChange() {
122
+        if SubscriptionStore.shared.isProActive {
123
+            if NSApp.isActive {
124
+                beginPremiumUsageSession()
125
+            }
126
+        } else {
127
+            postPurchaseWorkItem?.cancel()
128
+            postPurchaseWorkItem = nil
129
+            flushPremiumUsageSession()
130
+            stopUsageCheckTimer()
131
+            premiumUsageAccumulatedSeconds = 0
132
+        }
133
+    }
134
+
135
+    // MARK: - Usage tracking
136
+
137
+    private func beginPremiumUsageSession() {
138
+        guard SubscriptionStore.shared.isProActive else { return }
139
+        if premiumSessionStartedAt == nil {
140
+            premiumSessionStartedAt = Date()
141
+        }
142
+        startUsageCheckTimerIfNeeded()
143
+        evaluateUsageMilestone()
144
+    }
145
+
146
+    private func flushPremiumUsageSession() {
147
+        guard let startedAt = premiumSessionStartedAt else { return }
148
+        premiumUsageAccumulatedSeconds += Date().timeIntervalSince(startedAt)
149
+        premiumSessionStartedAt = nil
150
+    }
151
+
152
+    private func currentPremiumUsageSeconds() -> TimeInterval {
153
+        var total = premiumUsageAccumulatedSeconds
154
+        if let startedAt = premiumSessionStartedAt {
155
+            total += Date().timeIntervalSince(startedAt)
156
+        }
157
+        return total
158
+    }
159
+
160
+    private func startUsageCheckTimerIfNeeded() {
161
+        guard usageCheckTimer == nil else { return }
162
+        usageCheckTimer = Timer.scheduledTimer(withTimeInterval: usageCheckInterval, repeats: true) { [weak self] _ in
163
+            Task { @MainActor in
164
+                self?.evaluateUsageMilestone()
165
+            }
166
+        }
167
+    }
168
+
169
+    private func stopUsageCheckTimer() {
170
+        usageCheckTimer?.invalidate()
171
+        usageCheckTimer = nil
172
+    }
173
+
174
+    private func evaluateUsageMilestone() {
175
+        guard SubscriptionStore.shared.isProActive else { return }
176
+        guard !ratingPromptShownAfterUsage else { return }
177
+        guard currentPremiumUsageSeconds() >= usageMilestoneSeconds else { return }
178
+        requestReviewIfNeeded(trigger: .afterUsageMilestone)
179
+    }
180
+
181
+    // MARK: - Review prompt
182
+
183
+    private func requestReviewIfNeeded(trigger: ReviewTrigger) {
184
+        guard SubscriptionStore.shared.isProActive else { return }
185
+
186
+        switch trigger {
187
+        case .afterPurchase:
188
+            guard !ratingPromptShownAfterPurchase else { return }
189
+        case .afterUsageMilestone:
190
+            guard !ratingPromptShownAfterUsage else { return }
191
+        }
192
+
193
+        SKStoreReviewController.requestReview()
194
+
195
+        switch trigger {
196
+        case .afterPurchase:
197
+            ratingPromptShownAfterPurchase = true
198
+        case .afterUsageMilestone:
199
+            ratingPromptShownAfterUsage = true
200
+            flushPremiumUsageSession()
201
+            premiumUsageAccumulatedSeconds = 0
202
+            stopUsageCheckTimer()
203
+        }
204
+    }
205
+}