|
@@ -0,0 +1,157 @@
|
|
|
|
|
+import AppKit
|
|
|
|
|
+import Combine
|
|
|
|
|
+import Foundation
|
|
|
|
|
+import StoreKit
|
|
|
|
|
+
|
|
|
|
|
+enum RatingPromptSource {
|
|
|
|
|
+ case postPurchase
|
|
|
|
|
+ case usageMilestone
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@MainActor
|
|
|
|
|
+final class AppRatingManager: ObservableObject {
|
|
|
|
|
+ static let hasRatedKey = "hasRatedApp"
|
|
|
|
|
+ static let hasShownUsagePromptKey = "hasShownUsageRatingPrompt"
|
|
|
|
|
+
|
|
|
|
|
+ private static let continuousUsageThreshold: TimeInterval = 30 * 60
|
|
|
|
|
+
|
|
|
|
|
+ @Published private(set) var showRatingPrompt = false
|
|
|
|
|
+ @Published private(set) var ratingPromptSource: RatingPromptSource?
|
|
|
|
|
+
|
|
|
|
|
+ private let defaults: UserDefaults
|
|
|
|
|
+ private var isPremium = false
|
|
|
|
|
+ private var continuousSessionStart: Date?
|
|
|
|
|
+ private var usageTimerTask: Task<Void, Never>?
|
|
|
|
|
+ private var cancellables = Set<AnyCancellable>()
|
|
|
|
|
+
|
|
|
|
|
+ private var hasRatedApp: Bool {
|
|
|
|
|
+ defaults.bool(forKey: Self.hasRatedKey)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var hasShownUsagePrompt: Bool {
|
|
|
|
|
+ defaults.bool(forKey: Self.hasShownUsagePromptKey)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ init(defaults: UserDefaults = .standard) {
|
|
|
|
|
+ self.defaults = defaults
|
|
|
|
|
+ observeAppLifecycle()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func updatePremiumStatus(_ isPremium: Bool) {
|
|
|
|
|
+ self.isPremium = isPremium
|
|
|
|
|
+
|
|
|
|
|
+ guard isPremium, !hasRatedApp, !hasShownUsagePrompt else {
|
|
|
|
|
+ pauseUsageTracking()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if NSApplication.shared.isActive {
|
|
|
|
|
+ beginContinuousUsageSession()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func handleSubscriptionPurchased() {
|
|
|
|
|
+ guard !hasRatedApp else { return }
|
|
|
|
|
+
|
|
|
|
|
+ pauseUsageTracking()
|
|
|
|
|
+ ratingPromptSource = .postPurchase
|
|
|
|
|
+ showRatingPrompt = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func userAcceptedRating() {
|
|
|
|
|
+ showRatingPrompt = false
|
|
|
|
|
+ ratingPromptSource = nil
|
|
|
|
|
+ defaults.set(true, forKey: Self.hasRatedKey)
|
|
|
|
|
+ pauseUsageTracking()
|
|
|
|
|
+ requestAppStoreReview()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func userDeclinedRating() {
|
|
|
|
|
+ let source = ratingPromptSource
|
|
|
|
|
+ showRatingPrompt = false
|
|
|
|
|
+ ratingPromptSource = nil
|
|
|
|
|
+
|
|
|
|
|
+ if source == .usageMilestone {
|
|
|
|
|
+ defaults.set(true, forKey: Self.hasShownUsagePromptKey)
|
|
|
|
|
+ pauseUsageTracking()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ guard isPremium, !hasRatedApp, !hasShownUsagePrompt else { return }
|
|
|
|
|
+
|
|
|
|
|
+ if NSApplication.shared.isActive {
|
|
|
|
|
+ beginContinuousUsageSession()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func observeAppLifecycle() {
|
|
|
|
|
+ NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
|
|
|
|
|
+ .sink { [weak self] _ in
|
|
|
|
|
+ self?.appDidBecomeActive()
|
|
|
|
|
+ }
|
|
|
|
|
+ .store(in: &cancellables)
|
|
|
|
|
+
|
|
|
|
|
+ NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)
|
|
|
|
|
+ .sink { [weak self] _ in
|
|
|
|
|
+ self?.appDidResignActive()
|
|
|
|
|
+ }
|
|
|
|
|
+ .store(in: &cancellables)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func appDidBecomeActive() {
|
|
|
|
|
+ guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
|
|
|
|
|
+ beginContinuousUsageSession()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func appDidResignActive() {
|
|
|
|
|
+ pauseUsageTracking()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func beginContinuousUsageSession() {
|
|
|
|
|
+ guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
|
|
|
|
|
+
|
|
|
|
|
+ continuousSessionStart = Date()
|
|
|
|
|
+ startUsageTimer()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func pauseUsageTracking() {
|
|
|
|
|
+ continuousSessionStart = nil
|
|
|
|
|
+ usageTimerTask?.cancel()
|
|
|
|
|
+ usageTimerTask = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func startUsageTimer() {
|
|
|
|
|
+ usageTimerTask?.cancel()
|
|
|
|
|
+ usageTimerTask = Task { [weak self] in
|
|
|
|
|
+ while !Task.isCancelled {
|
|
|
|
|
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
|
|
|
+ guard !Task.isCancelled else { break }
|
|
|
|
|
+ self?.checkContinuousUsageThreshold()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func checkContinuousUsageThreshold() {
|
|
|
|
|
+ guard
|
|
|
|
|
+ let sessionStart = continuousSessionStart,
|
|
|
|
|
+ isPremium,
|
|
|
|
|
+ !hasRatedApp,
|
|
|
|
|
+ !hasShownUsagePrompt,
|
|
|
|
|
+ !showRatingPrompt
|
|
|
|
|
+ else {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let elapsed = Date().timeIntervalSince(sessionStart)
|
|
|
|
|
+ guard elapsed >= Self.continuousUsageThreshold else { return }
|
|
|
|
|
+
|
|
|
|
|
+ pauseUsageTracking()
|
|
|
|
|
+ defaults.set(true, forKey: Self.hasShownUsagePromptKey)
|
|
|
|
|
+ ratingPromptSource = .usageMilestone
|
|
|
|
|
+ showRatingPrompt = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func requestAppStoreReview() {
|
|
|
|
|
+ SKStoreReviewController.requestReview()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|