소스 검색

Make app rating Apple-compliant for premium users.

Replace post-purchase and custom alert prompts with delayed SKStoreReviewController requests after sustained usage, and add a premium-only Rate App option in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 주 전
부모
커밋
b15897874e

+ 209 - 0
App AI for Reddit/Managers/AppRatingManager.swift

@@ -0,0 +1,209 @@
+import AppKit
+import Combine
+import Foundation
+import StoreKit
+
+/// Schedules the system App Store review prompt after meaningful engagement.
+///
+/// Apple guidelines followed:
+/// - Uses `SKStoreReviewController` only (no custom pre-prompts or buttons).
+/// - Never requests immediately after purchase.
+/// - Waits for sustained usage, then applies a random delay before presenting.
+/// - Respects minimum spacing and yearly request limits on our side.
+@MainActor
+final class AppRatingManager: ObservableObject {
+    private enum Keys {
+        static let sessionCount = "appRating.sessionCount"
+        static let reviewRequestTimestamps = "appRating.reviewRequestTimestamps"
+    }
+
+    private static let continuousUsageThreshold: TimeInterval = 25 * 60
+    private static let minimumDaysBetweenRequests = 3
+    private static let maxReviewRequestsPerYear = 3
+    private static let minimumSessionsBeforeEligible = 2
+    private static let reviewDelayRange: ClosedRange<TimeInterval> = 30...120
+
+    private let defaults: UserDefaults
+    private var isPremium = false
+    private var continuousSessionStart: Date?
+    private var usageTimerTask: Task<Void, Never>?
+    private var reviewDelayTask: Task<Void, Never>?
+    private var cancellables = Set<AnyCancellable>()
+    private var pendingReviewAfterDelay = false
+
+    init(defaults: UserDefaults = .standard) {
+        self.defaults = defaults
+        incrementSessionCount()
+        observeAppLifecycle()
+    }
+
+    func updatePremiumStatus(_ isPremium: Bool) {
+        self.isPremium = isPremium
+        refreshUsageTracking()
+    }
+
+    private func incrementSessionCount() {
+        let count = defaults.integer(forKey: Keys.sessionCount)
+        defaults.set(count + 1, forKey: Keys.sessionCount)
+    }
+
+    private var sessionCount: Int {
+        defaults.integer(forKey: Keys.sessionCount)
+    }
+
+    private var reviewRequestTimestamps: [TimeInterval] {
+        get {
+            defaults.array(forKey: Keys.reviewRequestTimestamps) as? [TimeInterval] ?? []
+        }
+        set {
+            defaults.set(newValue, forKey: Keys.reviewRequestTimestamps)
+        }
+    }
+
+    private var isEligibleForReviewRequest: Bool {
+        guard sessionCount >= Self.minimumSessionsBeforeEligible else { return false }
+        guard daysSinceLastReviewRequest >= Self.minimumDaysBetweenRequests else { return false }
+        guard reviewRequestsInPastYear < Self.maxReviewRequestsPerYear else { return false }
+        return true
+    }
+
+    private var isEligibleForEngagementTracking: Bool {
+        isPremium && isEligibleForReviewRequest && !pendingReviewAfterDelay
+    }
+
+    private var daysSinceLastReviewRequest: Int {
+        guard let lastRequest = reviewRequestTimestamps.max() else { return Int.max }
+        let elapsed = Date().timeIntervalSince(Date(timeIntervalSince1970: lastRequest))
+        return Int(elapsed / 86_400)
+    }
+
+    private var reviewRequestsInPastYear: Int {
+        let oneYearAgo = Date().timeIntervalSince1970 - (365 * 86_400)
+        return reviewRequestTimestamps.filter { $0 >= oneYearAgo }.count
+    }
+
+    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() {
+        refreshUsageTracking()
+    }
+
+    private func appDidResignActive() {
+        pauseUsageTracking()
+        cancelPendingReviewRequest()
+    }
+
+    private func refreshUsageTracking() {
+        guard isEligibleForEngagementTracking else {
+            pauseUsageTracking()
+            return
+        }
+
+        guard NSApplication.shared.isActive else { return }
+        beginContinuousUsageSession()
+    }
+
+    private func beginContinuousUsageSession() {
+        guard isEligibleForEngagementTracking else { return }
+        guard continuousSessionStart == nil 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
+            isEligibleForEngagementTracking,
+            let sessionStart = continuousSessionStart
+        else {
+            return
+        }
+
+        let elapsed = Date().timeIntervalSince(sessionStart)
+        guard elapsed >= Self.continuousUsageThreshold else { return }
+
+        scheduleReviewRequestWithRandomDelay()
+    }
+
+    private func scheduleReviewRequestWithRandomDelay() {
+        guard isPremium, isEligibleForReviewRequest, !pendingReviewAfterDelay else { return }
+
+        pendingReviewAfterDelay = true
+        pauseUsageTracking()
+
+        let delay = Double.random(in: Self.reviewDelayRange)
+        reviewDelayTask?.cancel()
+        reviewDelayTask = Task { [weak self] in
+            try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+            guard !Task.isCancelled else { return }
+            await MainActor.run {
+                self?.presentReviewRequestIfAppropriate()
+            }
+        }
+    }
+
+    private func cancelPendingReviewRequest() {
+        guard pendingReviewAfterDelay else { return }
+
+        reviewDelayTask?.cancel()
+        reviewDelayTask = nil
+        pendingReviewAfterDelay = false
+    }
+
+    private func presentReviewRequestIfAppropriate() {
+        pendingReviewAfterDelay = false
+        reviewDelayTask = nil
+
+        guard isPremium, isEligibleForReviewRequest else { return }
+        guard NSApplication.shared.isActive else { return }
+
+        recordReviewRequestAttempt()
+        requestAppStoreReview()
+    }
+
+    private func recordReviewRequestAttempt() {
+        var timestamps = reviewRequestTimestamps
+        timestamps.append(Date().timeIntervalSince1970)
+
+        let oneYearAgo = Date().timeIntervalSince1970 - (365 * 86_400)
+        reviewRequestTimestamps = timestamps.filter { $0 >= oneYearAgo }
+    }
+
+    private func requestAppStoreReview() {
+        SKStoreReviewController.requestReview()
+    }
+
+    /// User-initiated review from Settings. Does not affect automatic prompt scheduling.
+    func requestReviewFromSettings() {
+        SKStoreReviewController.requestReview()
+    }
+}

+ 0 - 155
App AI for Reddit/Managers/SubscriptionManager.swift

@@ -586,158 +586,3 @@ final class SubscriptionManager: ObservableObject {
         }
     }
 }
-
-// MARK: - App Rating
-
-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()
-    }
-}

+ 1 - 0
App AI for Reddit/Utilities/AppLinks.swift

@@ -9,6 +9,7 @@ enum AppLinks {
     static let support = URL(string: "https://sites.google.com/view/appforreddit/get-support")!
     static let appStoreID = "6785831537"
     static let appStore = URL(string: "https://apps.apple.com/app/id\(appStoreID)")!
+    static let appStoreWriteReview = URL(string: "https://apps.apple.com/app/id\(appStoreID)?action=write-review")!
 
     static var shareMessage: String {
         "Check out \(appName) — AI-powered tools for Reddit posts, titles, and comments."

+ 0 - 17
App AI for Reddit/Views/FrontPageView.swift

@@ -79,23 +79,6 @@ struct FrontPageView: View {
             }
         }
         .background(AppTheme.background)
-        .alert("Enjoying \(AppLinks.appName)?", isPresented: Binding(
-            get: { appRating.showRatingPrompt },
-            set: { isPresented in
-                if !isPresented, appRating.showRatingPrompt {
-                    appRating.userDeclinedRating()
-                }
-            }
-        )) {
-            Button("Rate App") {
-                appRating.userAcceptedRating()
-            }
-            Button("Not Now", role: .cancel) {
-                appRating.userDeclinedRating()
-            }
-        } message: {
-            Text("Would you like to rate \(AppLinks.appName) on the App Store? Your feedback helps us improve.")
-        }
         .alert("Sign-in issue", isPresented: authErrorBinding) {
             Button("OK") {
                 viewModel.clearAuthError()

+ 0 - 3
App AI for Reddit/Views/PaywallView.swift

@@ -4,7 +4,6 @@ import SwiftUI
 
 struct PaywallView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
-    @EnvironmentObject private var appRating: AppRatingManager
     @ObservedObject private var paywallConfigService = PaywallConfigService.shared
     @Bindable var viewModel: PaywallViewModel
     var onDismiss: () -> Void
@@ -219,7 +218,6 @@ struct PaywallView: View {
                 Task {
                     let purchased = await subscriptions.purchase(viewModel.selectedPlan)
                     if purchased {
-                        appRating.handleSubscriptionPurchased()
                         onDismiss()
                     }
                 }
@@ -509,7 +507,6 @@ private struct PaywallPricingCard: View {
     let subscriptions = SubscriptionManager()
     return PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
         .environmentObject(subscriptions)
-        .environmentObject(AppRatingManager())
         .frame(width: AppWindow.width, height: AppWindow.height)
         .onAppear {
             subscriptions.start()

+ 20 - 4
App AI for Reddit/Views/SettingsView.swift

@@ -2,6 +2,8 @@ import SwiftUI
 
 struct SettingsView: View {
     @Bindable var viewModel: SettingsViewModel
+    @EnvironmentObject private var subscriptions: SubscriptionManager
+    @EnvironmentObject private var appRating: AppRatingManager
     @Environment(AppearanceManager.self) private var appearance
 
     private enum Layout {
@@ -72,10 +74,22 @@ struct SettingsView: View {
             title: "Share",
             subtitle: "Spread the word about \(AppLinks.appName)"
         ) {
-            SettingsShareAppButton(
-                message: viewModel.shareMessage,
-                url: viewModel.shareURL
-            )
+            VStack(spacing: 8) {
+                SettingsShareAppButton(
+                    message: viewModel.shareMessage,
+                    url: viewModel.shareURL
+                )
+
+                if subscriptions.hasPremiumAccess {
+                    SettingsLinkRow(
+                        icon: "star.fill",
+                        title: "Rate App",
+                        subtitle: "Leave a review on the App Store"
+                    ) {
+                        appRating.requestReviewFromSettings()
+                    }
+                }
+            }
         }
     }
 
@@ -123,6 +137,8 @@ struct SettingsView: View {
 
 #Preview {
     SettingsView(viewModel: SettingsViewModel())
+        .environmentObject(SubscriptionManager())
+        .environmentObject(AppRatingManager())
         .environment(AppearanceManager.shared)
         .frame(width: 800, height: 600)
 }