Forráskód Böngészése

Add App Store rating prompts for premium users after purchase and sustained use.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 hónapja
szülő
commit
d2a9490190

+ 2 - 0
Reddit App/App/RedditAppApp.swift

@@ -4,12 +4,14 @@ import SwiftUI
 @main
 struct RedditAppApp: App {
     @StateObject private var subscriptions = SubscriptionManager()
+    @StateObject private var appRating = AppRatingManager()
     @State private var appearanceManager = AppearanceManager.shared
 
     var body: some Scene {
         WindowGroup {
             FrontPageView()
                 .environmentObject(subscriptions)
+                .environmentObject(appRating)
                 .environment(appearanceManager)
                 .preferredColorScheme(appearanceManager.isDarkMode ? .dark : .light)
         }

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

@@ -337,3 +337,158 @@ 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()
+    }
+}

+ 23 - 0
Reddit App/Views/FrontPageView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct FrontPageView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
+    @EnvironmentObject private var appRating: AppRatingManager
     @Environment(AppearanceManager.self) private var appearanceManager
     @State private var viewModel = FrontPageViewModel()
     @State private var paywallViewModel = PaywallViewModel()
@@ -44,9 +45,30 @@ struct FrontPageView: View {
             if hasPremium {
                 viewModel.dismissPaywall()
             }
+            appRating.updatePremiumStatus(hasPremium)
+        }
+        .onAppear {
+            appRating.updatePremiumStatus(subscriptions.hasPremiumAccess)
         }
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .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()
@@ -139,6 +161,7 @@ private struct ToolPlaceholderView: View {
 #Preview {
     FrontPageView()
         .environmentObject(SubscriptionManager())
+        .environmentObject(AppRatingManager())
         .environment(AppearanceManager.shared)
         .frame(width: AppWindow.width, height: AppWindow.height)
 }

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

@@ -3,6 +3,7 @@ import SwiftUI
 
 struct PaywallView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
+    @EnvironmentObject private var appRating: AppRatingManager
     @Bindable var viewModel: PaywallViewModel
     var onDismiss: () -> Void
 
@@ -158,6 +159,7 @@ struct PaywallView: View {
                 Task {
                     let purchased = await subscriptions.purchase(viewModel.selectedPlan)
                     if purchased {
+                        appRating.handleSubscriptionPurchased()
                         onDismiss()
                     }
                 }
@@ -412,5 +414,6 @@ private struct PaywallPricingCard: View {
 #Preview {
     PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
         .environmentObject(SubscriptionManager())
+        .environmentObject(AppRatingManager())
         .frame(width: AppWindow.width, height: AppWindow.height)
 }