|
@@ -3,20 +3,43 @@ import Combine
|
|
|
import Foundation
|
|
import Foundation
|
|
|
import StoreKit
|
|
import StoreKit
|
|
|
|
|
|
|
|
-enum RatingPromptSource {
|
|
|
|
|
- case postPurchase
|
|
|
|
|
- case usageMilestone
|
|
|
|
|
|
|
+extension Notification.Name {
|
|
|
|
|
+ static let historyEntryDidSave = Notification.Name("historyEntryDidSave")
|
|
|
|
|
+ static let toolOutputDidCopy = Notification.Name("toolOutputDidCopy")
|
|
|
|
|
+ static let subscriptionPurchased = Notification.Name("subscriptionPurchased")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+enum RatingMilestone {
|
|
|
|
|
+ static func notifyOutputCopied() {
|
|
|
|
|
+ NotificationCenter.default.post(name: .toolOutputDidCopy, object: nil)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
final class AppRatingManager: ObservableObject {
|
|
final class AppRatingManager: ObservableObject {
|
|
|
static let hasRatedKey = "hasRatedApp"
|
|
static let hasRatedKey = "hasRatedApp"
|
|
|
static let hasShownUsagePromptKey = "hasShownUsageRatingPrompt"
|
|
static let hasShownUsagePromptKey = "hasShownUsageRatingPrompt"
|
|
|
-
|
|
|
|
|
- private static let continuousUsageThreshold: TimeInterval = 30 * 60
|
|
|
|
|
|
|
+ static let toolUseCountKey = "ratingToolUseCount"
|
|
|
|
|
+ static let copyCountKey = "ratingCopyCount"
|
|
|
|
|
+ static let activeDaysKey = "ratingActiveDays"
|
|
|
|
|
+ static let promptCooldownUntilKey = "ratingPromptCooldownUntil"
|
|
|
|
|
+
|
|
|
|
|
+ private static let continuousUsageThreshold: TimeInterval = 10 * 60
|
|
|
|
|
+ private static let successfulToolUseThreshold = 2
|
|
|
|
|
+ private static let copyOutputThreshold = 1
|
|
|
|
|
+ private static let activeDaysThreshold = 2
|
|
|
|
|
+ private static let postPurchaseCooldown: TimeInterval = 60 * 60
|
|
|
|
|
+
|
|
|
|
|
+ private static let dayFormatter: DateFormatter = {
|
|
|
|
|
+ let formatter = DateFormatter()
|
|
|
|
|
+ formatter.calendar = Calendar.current
|
|
|
|
|
+ formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
|
|
|
+ formatter.timeZone = .current
|
|
|
|
|
+ formatter.dateFormat = "yyyy-MM-dd"
|
|
|
|
|
+ return formatter
|
|
|
|
|
+ }()
|
|
|
|
|
|
|
|
@Published private(set) var showRatingPrompt = false
|
|
@Published private(set) var showRatingPrompt = false
|
|
|
- @Published private(set) var ratingPromptSource: RatingPromptSource?
|
|
|
|
|
|
|
|
|
|
private let defaults: UserDefaults
|
|
private let defaults: UserDefaults
|
|
|
private var isPremium = false
|
|
private var isPremium = false
|
|
@@ -32,9 +55,17 @@ final class AppRatingManager: ObservableObject {
|
|
|
defaults.bool(forKey: Self.hasShownUsagePromptKey)
|
|
defaults.bool(forKey: Self.hasShownUsagePromptKey)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private var isWithinPurchaseCooldown: Bool {
|
|
|
|
|
+ guard let cooldownUntil = defaults.object(forKey: Self.promptCooldownUntilKey) as? Date else {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return Date() < cooldownUntil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
init(defaults: UserDefaults = .standard) {
|
|
|
self.defaults = defaults
|
|
self.defaults = defaults
|
|
|
observeAppLifecycle()
|
|
observeAppLifecycle()
|
|
|
|
|
+ observeEngagementMilestones()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func updatePremiumStatus(_ isPremium: Bool) {
|
|
func updatePremiumStatus(_ isPremium: Bool) {
|
|
@@ -45,43 +76,47 @@ final class AppRatingManager: ObservableObject {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ recordActiveDayIfNeeded()
|
|
|
|
|
+
|
|
|
if NSApplication.shared.isActive {
|
|
if NSApplication.shared.isActive {
|
|
|
beginContinuousUsageSession()
|
|
beginContinuousUsageSession()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func handleSubscriptionPurchased() {
|
|
|
|
|
- guard !hasRatedApp else { return }
|
|
|
|
|
-
|
|
|
|
|
- pauseUsageTracking()
|
|
|
|
|
- ratingPromptSource = .postPurchase
|
|
|
|
|
- showRatingPrompt = true
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
func userAcceptedRating() {
|
|
func userAcceptedRating() {
|
|
|
showRatingPrompt = false
|
|
showRatingPrompt = false
|
|
|
- ratingPromptSource = nil
|
|
|
|
|
defaults.set(true, forKey: Self.hasRatedKey)
|
|
defaults.set(true, forKey: Self.hasRatedKey)
|
|
|
pauseUsageTracking()
|
|
pauseUsageTracking()
|
|
|
requestAppStoreReview()
|
|
requestAppStoreReview()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func userDeclinedRating() {
|
|
func userDeclinedRating() {
|
|
|
- let source = ratingPromptSource
|
|
|
|
|
showRatingPrompt = false
|
|
showRatingPrompt = false
|
|
|
- ratingPromptSource = nil
|
|
|
|
|
|
|
+ defaults.set(true, forKey: Self.hasShownUsagePromptKey)
|
|
|
|
|
+ pauseUsageTracking()
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if source == .usageMilestone {
|
|
|
|
|
- defaults.set(true, forKey: Self.hasShownUsagePromptKey)
|
|
|
|
|
- pauseUsageTracking()
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ private func observeEngagementMilestones() {
|
|
|
|
|
+ NotificationCenter.default.publisher(for: .historyEntryDidSave)
|
|
|
|
|
+ .receive(on: DispatchQueue.main)
|
|
|
|
|
+ .sink { [weak self] _ in
|
|
|
|
|
+ self?.recordSuccessfulToolUse()
|
|
|
|
|
+ }
|
|
|
|
|
+ .store(in: &cancellables)
|
|
|
|
|
|
|
|
- guard isPremium, !hasRatedApp, !hasShownUsagePrompt else { return }
|
|
|
|
|
|
|
+ NotificationCenter.default.publisher(for: .toolOutputDidCopy)
|
|
|
|
|
+ .receive(on: DispatchQueue.main)
|
|
|
|
|
+ .sink { [weak self] _ in
|
|
|
|
|
+ self?.recordCopiedOutput()
|
|
|
|
|
+ }
|
|
|
|
|
+ .store(in: &cancellables)
|
|
|
|
|
|
|
|
- if NSApplication.shared.isActive {
|
|
|
|
|
- beginContinuousUsageSession()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ NotificationCenter.default.publisher(for: .subscriptionPurchased)
|
|
|
|
|
+ .receive(on: DispatchQueue.main)
|
|
|
|
|
+ .sink { [weak self] _ in
|
|
|
|
|
+ self?.beginPostPurchaseCooldown()
|
|
|
|
|
+ }
|
|
|
|
|
+ .store(in: &cancellables)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func observeAppLifecycle() {
|
|
private func observeAppLifecycle() {
|
|
@@ -100,6 +135,8 @@ final class AppRatingManager: ObservableObject {
|
|
|
|
|
|
|
|
private func appDidBecomeActive() {
|
|
private func appDidBecomeActive() {
|
|
|
guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
|
|
guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
|
|
|
|
|
+
|
|
|
|
|
+ recordActiveDayIfNeeded()
|
|
|
beginContinuousUsageSession()
|
|
beginContinuousUsageSession()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -145,9 +182,60 @@ final class AppRatingManager: ObservableObject {
|
|
|
let elapsed = Date().timeIntervalSince(sessionStart)
|
|
let elapsed = Date().timeIntervalSince(sessionStart)
|
|
|
guard elapsed >= Self.continuousUsageThreshold else { return }
|
|
guard elapsed >= Self.continuousUsageThreshold else { return }
|
|
|
|
|
|
|
|
|
|
+ if isWithinPurchaseCooldown {
|
|
|
|
|
+ pauseUsageTracking()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tryPresentRatingPrompt()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func recordSuccessfulToolUse() {
|
|
|
|
|
+ guard isPremium, !hasRatedApp, !hasShownUsagePrompt else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let count = defaults.integer(forKey: Self.toolUseCountKey) + 1
|
|
|
|
|
+ defaults.set(count, forKey: Self.toolUseCountKey)
|
|
|
|
|
+
|
|
|
|
|
+ guard count >= Self.successfulToolUseThreshold else { return }
|
|
|
|
|
+ tryPresentRatingPrompt()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func recordCopiedOutput() {
|
|
|
|
|
+ guard isPremium, !hasRatedApp, !hasShownUsagePrompt else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let count = defaults.integer(forKey: Self.copyCountKey) + 1
|
|
|
|
|
+ defaults.set(count, forKey: Self.copyCountKey)
|
|
|
|
|
+
|
|
|
|
|
+ guard count >= Self.copyOutputThreshold else { return }
|
|
|
|
|
+ tryPresentRatingPrompt()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func recordActiveDayIfNeeded() {
|
|
|
|
|
+ guard isPremium, !hasRatedApp, !hasShownUsagePrompt else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let today = Self.dayFormatter.string(from: Date())
|
|
|
|
|
+ var days = Set(defaults.stringArray(forKey: Self.activeDaysKey) ?? [])
|
|
|
|
|
+
|
|
|
|
|
+ guard days.insert(today).inserted else { return }
|
|
|
|
|
+
|
|
|
|
|
+ defaults.set(Array(days), forKey: Self.activeDaysKey)
|
|
|
|
|
+
|
|
|
|
|
+ guard days.count >= Self.activeDaysThreshold else { return }
|
|
|
|
|
+ tryPresentRatingPrompt()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func beginPostPurchaseCooldown() {
|
|
|
|
|
+ let cooldownUntil = Date().addingTimeInterval(Self.postPurchaseCooldown)
|
|
|
|
|
+ defaults.set(cooldownUntil, forKey: Self.promptCooldownUntilKey)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func tryPresentRatingPrompt() {
|
|
|
|
|
+ guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt, !isWithinPurchaseCooldown else {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
pauseUsageTracking()
|
|
pauseUsageTracking()
|
|
|
defaults.set(true, forKey: Self.hasShownUsagePromptKey)
|
|
defaults.set(true, forKey: Self.hasShownUsagePromptKey)
|
|
|
- ratingPromptSource = .usageMilestone
|
|
|
|
|
showRatingPrompt = true
|
|
showRatingPrompt = true
|
|
|
}
|
|
}
|
|
|
|
|
|