Переглянути джерело

Add post-subscription app rating prompts for premium users.

Prompt subscribers to rate after purchase, with a fallback after 30 minutes of continuous use if they decline.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 місяць тому
батько
коміт
24633f53b2

+ 2 - 0
gramora/App/GramoraApp.swift

@@ -4,12 +4,14 @@ import SwiftUI
 struct GramoraApp: App {
     @StateObject private var subscriptions = SubscriptionManager()
     @StateObject private var themeManager = ThemeManager()
+    @StateObject private var appRating = AppRatingManager()
 
     var body: some Scene {
         WindowGroup {
             MainView()
                 .environmentObject(subscriptions)
                 .environmentObject(themeManager)
+                .environmentObject(appRating)
                 .frame(minWidth: AppTheme.windowMinWidth, minHeight: AppTheme.windowMinHeight)
                 .initialWindowSize(width: AppTheme.windowWidth, height: AppTheme.windowHeight)
                 .configureWindowChrome()

+ 157 - 0
gramora/Managers/AppRatingManager.swift

@@ -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()
+    }
+}

+ 22 - 0
gramora/Views/MainView.swift

@@ -4,6 +4,7 @@ struct MainView: View {
     @StateObject private var viewModel = MainViewModel()
     @EnvironmentObject private var subscriptions: SubscriptionManager
     @EnvironmentObject private var themeManager: ThemeManager
+    @EnvironmentObject private var appRating: AppRatingManager
 
     var body: some View {
         HStack(spacing: 0) {
@@ -72,6 +73,27 @@ struct MainView: View {
             if hasPremium {
                 viewModel.hidePaywall()
             }
+            appRating.updatePremiumStatus(hasPremium)
+        }
+        .onAppear {
+            appRating.updatePremiumStatus(subscriptions.hasPremiumAccess)
+        }
+        .alert("Enjoying Gramora?", 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 Gramora on the App Store? Your feedback helps us improve.")
         }
     }
 

+ 2 - 0
gramora/Views/PaywallView.swift

@@ -2,6 +2,7 @@ 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
@@ -149,6 +150,7 @@ struct PaywallView: View {
                 Task {
                     let purchased = await subscriptions.purchase(viewModel.selectedPlan)
                     if purchased {
+                        appRating.handleSubscriptionPurchased()
                         onClose()
                     }
                 }