Przeglądaj źródła

Fix App Store rating prompts to comply with Guideline 5.6.1.

Remove the post-purchase review ask and replace it with organic engagement triggers, a one-hour purchase cooldown, and a Settings link to rate on the App Store.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hussain Afzal 4 tygodni temu
rodzic
commit
3934bb772c

+ 115 - 27
gramora/Managers/AppRatingManager.swift

@@ -3,20 +3,43 @@ import Combine
 import Foundation
 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
 final class AppRatingManager: ObservableObject {
     static let hasRatedKey = "hasRatedApp"
     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 ratingPromptSource: RatingPromptSource?
 
     private let defaults: UserDefaults
     private var isPremium = false
@@ -32,9 +55,17 @@ final class AppRatingManager: ObservableObject {
         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) {
         self.defaults = defaults
         observeAppLifecycle()
+        observeEngagementMilestones()
     }
 
     func updatePremiumStatus(_ isPremium: Bool) {
@@ -45,43 +76,47 @@ final class AppRatingManager: ObservableObject {
             return
         }
 
+        recordActiveDayIfNeeded()
+
         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
+        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() {
@@ -100,6 +135,8 @@ final class AppRatingManager: ObservableObject {
 
     private func appDidBecomeActive() {
         guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
+
+        recordActiveDayIfNeeded()
         beginContinuousUsageSession()
     }
 
@@ -145,9 +182,60 @@ final class AppRatingManager: ObservableObject {
         let elapsed = Date().timeIntervalSince(sessionStart)
         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()
         defaults.set(true, forKey: Self.hasShownUsagePromptKey)
-        ratingPromptSource = .usageMilestone
         showRatingPrompt = true
     }
 

+ 1 - 0
gramora/Managers/SubscriptionManager.swift

@@ -222,6 +222,7 @@ final class SubscriptionManager: ObservableObject {
                     purchaseError = .purchaseNotVerified
                     return false
                 }
+                NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
                 await transaction.finish()
                 await refreshPremiumAccess()
                 return hasPremiumAccess

+ 1 - 0
gramora/Services/HistoryStorageService.swift

@@ -75,6 +75,7 @@ final class HistoryStorageService: HistoryStoring {
             defaults.set(data, forKey: storageKey)
             DispatchQueue.main.async {
                 NotificationCenter.default.post(name: .historyDidChange, object: nil)
+                NotificationCenter.default.post(name: .historyEntryDidSave, object: nil)
             }
         } catch {
             logger.error("Failed to persist history entries: \(error.localizedDescription, privacy: .public)")

+ 1 - 0
gramora/Utilities/AppLinks.swift

@@ -6,6 +6,7 @@ enum AppLinks {
     static let privacy = URL(string: "https://sites.google.com/view/grammar-pro-ai-writing-app/privacy-policy")!
     static let terms = URL(string: "https://sites.google.com/view/grammar-pro-ai-writing-app/terms-and-condition")!
     static let support = URL(string: "https://sites.google.com/view/grammar-pro-ai-writing-app/get-support")!
+    static let appStoreReview = URL(string: "https://apps.apple.com/app/id6785216263?action=write-review")!
 
     static func open(_ url: URL) {
         NSWorkspace.shared.open(url)

+ 1 - 0
gramora/ViewModels/EmailWriterViewModel.swift

@@ -71,6 +71,7 @@ final class EmailWriterViewModel: ObservableObject {
         guard canCopy else { return }
         NSPasteboard.general.clearContents()
         NSPasteboard.general.setString(generatedEmail, forType: .string)
+        RatingMilestone.notifyOutputCopied()
     }
 
     func generateEmail() {

+ 1 - 0
gramora/ViewModels/GrammarCheckerViewModel.swift

@@ -116,6 +116,7 @@ final class GrammarCheckerViewModel: ObservableObject {
         guard !correctedText.isEmpty else { return }
         NSPasteboard.general.clearContents()
         NSPasteboard.general.setString(correctedText, forType: .string)
+        RatingMilestone.notifyOutputCopied()
     }
 
     func restore(from entry: HistoryEntry) {

+ 1 - 0
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -170,5 +170,6 @@ final class LanguageTranslatorViewModel: ObservableObject {
         guard !translatedText.isEmpty else { return }
         NSPasteboard.general.clearContents()
         NSPasteboard.general.setString(translatedText, forType: .string)
+        RatingMilestone.notifyOutputCopied()
     }
 }

+ 1 - 0
gramora/ViewModels/ParaphrasingViewModel.swift

@@ -130,5 +130,6 @@ final class ParaphrasingViewModel: ObservableObject {
         guard !paraphrasedText.isEmpty else { return }
         NSPasteboard.general.clearContents()
         NSPasteboard.general.setString(paraphrasedText, forType: .string)
+        RatingMilestone.notifyOutputCopied()
     }
 }

+ 1 - 0
gramora/ViewModels/PunctuationCheckerViewModel.swift

@@ -142,5 +142,6 @@ final class PunctuationCheckerViewModel: ObservableObject {
         guard !correctedText.isEmpty else { return }
         NSPasteboard.general.clearContents()
         NSPasteboard.general.setString(correctedText, forType: .string)
+        RatingMilestone.notifyOutputCopied()
     }
 }

+ 1 - 0
gramora/ViewModels/SpellCheckerViewModel.swift

@@ -147,5 +147,6 @@ final class SpellCheckerViewModel: ObservableObject {
         guard !correctedText.isEmpty else { return }
         NSPasteboard.general.clearContents()
         NSPasteboard.general.setString(correctedText, forType: .string)
+        RatingMilestone.notifyOutputCopied()
     }
 }

+ 1 - 0
gramora/ViewModels/SummarizerViewModel.swift

@@ -130,5 +130,6 @@ final class SummarizerViewModel: ObservableObject {
         guard !summary.isEmpty else { return }
         NSPasteboard.general.clearContents()
         NSPasteboard.general.setString(summary, forType: .string)
+        RatingMilestone.notifyOutputCopied()
     }
 }

+ 0 - 2
gramora/Views/PaywallView.swift

@@ -2,7 +2,6 @@ import SwiftUI
 
 struct PaywallView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
-    @EnvironmentObject private var appRating: AppRatingManager
     @StateObject private var viewModel = PaywallViewModel()
     let onClose: () -> Void
     @State private var isCTAHovered = false
@@ -163,7 +162,6 @@ struct PaywallView: View {
                 Task {
                     let purchased = await subscriptions.purchase(viewModel.selectedPlan)
                     if purchased {
-                        appRating.handleSubscriptionPurchased()
                         onClose()
                     }
                 }

+ 2 - 0
gramora/Views/SettingsView.swift

@@ -143,6 +143,8 @@ struct SettingsView: View {
                 SettingsLinkRow(iconName: "doc.text.fill", title: "Terms and Conditions", url: AppLinks.terms)
                 SettingsRowDivider()
                 SettingsLinkRow(iconName: "questionmark.circle.fill", title: "Support", url: AppLinks.support)
+                SettingsRowDivider()
+                SettingsLinkRow(iconName: "star.fill", title: "Rate on App Store", url: AppLinks.appStoreReview)
             }
         }
     }