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