|
|
@@ -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
|
+}
|