Browse Source

Refine premium rating prompts by gating Rate Us behind premium usage eligibility and scheduling a delayed review request after successful upgrades.

This keeps rating visibility aligned with business rules while avoiding stale legacy rating flags that blocked retesting.

Made-with: Cursor
huzaifahayat12 1 week ago
parent
commit
b75ddc0f46
1 changed files with 139 additions and 7 deletions
  1. 139 7
      meetings_app/ViewController.swift

+ 139 - 7
meetings_app/ViewController.swift

@@ -283,6 +283,9 @@ final class ViewController: NSViewController {
283
     private var hasViewAppearedOnce = false
283
     private var hasViewAppearedOnce = false
284
     private var lastKnownPremiumAccess = false
284
     private var lastKnownPremiumAccess = false
285
     private var displayedScheduleMeetings: [ScheduledMeeting] = []
285
     private var displayedScheduleMeetings: [ScheduledMeeting] = []
286
+    private var appUsageSessionStartDate: Date?
287
+    private var hasObservedAppLifecycleForUsage = false
288
+    private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
286
 
289
 
287
     private enum ScheduleFilter: Int {
290
     private enum ScheduleFilter: Int {
288
         case all = 0
291
         case all = 0
@@ -316,6 +319,10 @@ final class ViewController: NSViewController {
316
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
319
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
317
 
320
 
318
     private let darkModeDefaultsKey = "settings.darkModeEnabled"
321
     private let darkModeDefaultsKey = "settings.darkModeEnabled"
322
+    private let appUsageAccumulatedSecondsDefaultsKey = "rating.appUsageAccumulatedSeconds"
323
+    private let userHasRatedDefaultsKey = "rating.userHasRated"
324
+    private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
325
+    private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
319
     private var darkModeEnabled: Bool {
326
     private var darkModeEnabled: Bool {
320
         get {
327
         get {
321
             let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
328
             let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
@@ -329,10 +336,12 @@ final class ViewController: NSViewController {
329
         popover.behavior = .transient
336
         popover.behavior = .transient
330
         popover.animates = true
337
         popover.animates = true
331
         let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
338
         let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
339
+        let showRateUsInSettings = shouldShowRateUsInSettings
332
         popover.contentViewController = SettingsMenuViewController(
340
         popover.contentViewController = SettingsMenuViewController(
333
             palette: palette,
341
             palette: palette,
334
             typography: typography,
342
             typography: typography,
335
             darkModeEnabled: darkModeEnabled,
343
             darkModeEnabled: darkModeEnabled,
344
+            showRateUsInSettings: showRateUsInSettings,
336
             showUpgradeInSettings: showUpgradeInSettings,
345
             showUpgradeInSettings: showUpgradeInSettings,
337
             onToggleDarkMode: { [weak self] enabled in
346
             onToggleDarkMode: { [weak self] enabled in
338
                 self?.setDarkMode(enabled)
347
                 self?.setDarkMode(enabled)
@@ -355,6 +364,9 @@ final class ViewController: NSViewController {
355
             guard let self else { return }
364
             guard let self else { return }
356
             self.handlePremiumAccessChanged(hasPremiumAccess)
365
             self.handlePremiumAccessChanged(hasPremiumAccess)
357
         }
366
         }
367
+        migrateLegacyRatingStateIfNeeded()
368
+        beginUsageTrackingSessionIfNeeded()
369
+        observeAppLifecycleForUsageTrackingIfNeeded()
358
         setupRootView()
370
         setupRootView()
359
         buildMainLayout()
371
         buildMainLayout()
360
         startStoreKit()
372
         startStoreKit()
@@ -397,6 +409,11 @@ final class ViewController: NSViewController {
397
     }
409
     }
398
 
410
 
399
     deinit {
411
     deinit {
412
+        premiumUpgradeRatingPromptWorkItem?.cancel()
413
+        endUsageTrackingSession()
414
+        if hasObservedAppLifecycleForUsage {
415
+            NotificationCenter.default.removeObserver(self)
416
+        }
400
         storeKitStartupTask?.cancel()
417
         storeKitStartupTask?.cancel()
401
         paywallPurchaseTask?.cancel()
418
         paywallPurchaseTask?.cancel()
402
         launchPaywallWorkItem?.cancel()
419
         launchPaywallWorkItem?.cancel()
@@ -764,10 +781,7 @@ private extension ViewController {
764
         case .rateUs:
781
         case .rateUs:
765
             settingsPopover?.performClose(nil)
782
             settingsPopover?.performClose(nil)
766
             settingsPopover = nil
783
             settingsPopover = nil
767
-            // Replace with your App Store product URL when the app is listed.
768
-            if let url = URL(string: "https://apps.apple.com/app/id0000000000") {
769
-                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
770
-            }
784
+            requestAppRatingIfEligible(markAsRated: true)
771
         case .support:
785
         case .support:
772
             settingsPopover?.performClose(nil)
786
             settingsPopover?.performClose(nil)
773
             settingsPopover = nil
787
             settingsPopover = nil
@@ -1038,18 +1052,124 @@ private extension ViewController {
1038
     private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
1052
     private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
1039
         let hadPremiumAccess = lastKnownPremiumAccess
1053
         let hadPremiumAccess = lastKnownPremiumAccess
1040
         lastKnownPremiumAccess = hasPremiumAccess
1054
         lastKnownPremiumAccess = hasPremiumAccess
1055
+        premiumUpgradeRatingPromptWorkItem?.cancel()
1041
         refreshPaywallStoreUI()
1056
         refreshPaywallStoreUI()
1042
         refreshScheduleCardsForPremiumStateChange()
1057
         refreshScheduleCardsForPremiumStateChange()
1043
         if !hadPremiumAccess && hasPremiumAccess {
1058
         if !hadPremiumAccess && hasPremiumAccess {
1044
             Task { [weak self] in
1059
             Task { [weak self] in
1045
                 await self?.loadSchedule()
1060
                 await self?.loadSchedule()
1046
             }
1061
             }
1062
+            scheduleRatingPromptAfterPremiumUpgrade()
1047
         }
1063
         }
1048
         if hadPremiumAccess && !hasPremiumAccess {
1064
         if hadPremiumAccess && !hasPremiumAccess {
1049
             showPaywall()
1065
             showPaywall()
1050
         }
1066
         }
1051
     }
1067
     }
1052
 
1068
 
1069
+    private var userHasRated: Bool {
1070
+        UserDefaults.standard.bool(forKey: userHasRatedDefaultsKey)
1071
+    }
1072
+
1073
+    private var accumulatedAppUsageSeconds: TimeInterval {
1074
+        get {
1075
+            UserDefaults.standard.double(forKey: appUsageAccumulatedSecondsDefaultsKey)
1076
+        }
1077
+        set {
1078
+            UserDefaults.standard.set(newValue, forKey: appUsageAccumulatedSecondsDefaultsKey)
1079
+        }
1080
+    }
1081
+
1082
+    private var totalTrackedUsageSeconds: TimeInterval {
1083
+        let liveSessionSeconds: TimeInterval
1084
+        if let start = appUsageSessionStartDate {
1085
+            liveSessionSeconds = max(0, Date().timeIntervalSince(start))
1086
+        } else {
1087
+            liveSessionSeconds = 0
1088
+        }
1089
+        return accumulatedAppUsageSeconds + liveSessionSeconds
1090
+    }
1091
+
1092
+    private var hasReachedRatingUsageThreshold: Bool {
1093
+        totalTrackedUsageSeconds >= ratingEligibleUsageSeconds
1094
+    }
1095
+
1096
+    private var shouldShowRateUsInSettings: Bool {
1097
+        storeKitCoordinator.hasPremiumAccess && !userHasRated && hasReachedRatingUsageThreshold
1098
+    }
1099
+
1100
+    private func migrateLegacyRatingStateIfNeeded() {
1101
+        let defaults = UserDefaults.standard
1102
+        guard !defaults.bool(forKey: ratingStateMigrationV2DoneDefaultsKey) else { return }
1103
+        // Legacy behavior marked "rated" immediately after requesting review.
1104
+        // Clear once so testing and new logic can run correctly.
1105
+        defaults.set(false, forKey: userHasRatedDefaultsKey)
1106
+        defaults.set(true, forKey: ratingStateMigrationV2DoneDefaultsKey)
1107
+    }
1108
+
1109
+    private func beginUsageTrackingSessionIfNeeded() {
1110
+        guard appUsageSessionStartDate == nil else { return }
1111
+        appUsageSessionStartDate = Date()
1112
+    }
1113
+
1114
+    private func endUsageTrackingSession() {
1115
+        guard let start = appUsageSessionStartDate else { return }
1116
+        let sessionElapsedSeconds = max(0, Date().timeIntervalSince(start))
1117
+        accumulatedAppUsageSeconds += sessionElapsedSeconds
1118
+        appUsageSessionStartDate = nil
1119
+    }
1120
+
1121
+    private func observeAppLifecycleForUsageTrackingIfNeeded() {
1122
+        guard !hasObservedAppLifecycleForUsage else { return }
1123
+        hasObservedAppLifecycleForUsage = true
1124
+        NotificationCenter.default.addObserver(
1125
+            self,
1126
+            selector: #selector(applicationDidBecomeActiveForUsageTracking),
1127
+            name: NSApplication.didBecomeActiveNotification,
1128
+            object: nil
1129
+        )
1130
+        NotificationCenter.default.addObserver(
1131
+            self,
1132
+            selector: #selector(applicationWillResignActiveForUsageTracking),
1133
+            name: NSApplication.willResignActiveNotification,
1134
+            object: nil
1135
+        )
1136
+        NotificationCenter.default.addObserver(
1137
+            self,
1138
+            selector: #selector(applicationWillTerminateForUsageTracking),
1139
+            name: NSApplication.willTerminateNotification,
1140
+            object: nil
1141
+        )
1142
+    }
1143
+
1144
+    @objc private func applicationDidBecomeActiveForUsageTracking() {
1145
+        beginUsageTrackingSessionIfNeeded()
1146
+    }
1147
+
1148
+    @objc private func applicationWillResignActiveForUsageTracking() {
1149
+        endUsageTrackingSession()
1150
+    }
1151
+
1152
+    @objc private func applicationWillTerminateForUsageTracking() {
1153
+        endUsageTrackingSession()
1154
+    }
1155
+
1156
+    private func scheduleRatingPromptAfterPremiumUpgrade() {
1157
+        guard !userHasRated else { return }
1158
+        let workItem = DispatchWorkItem { [weak self] in
1159
+            self?.requestAppRatingIfEligible(markAsRated: false)
1160
+        }
1161
+        premiumUpgradeRatingPromptWorkItem = workItem
1162
+        DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: workItem)
1163
+    }
1164
+
1165
+    private func requestAppRatingIfEligible(markAsRated: Bool) {
1166
+        guard storeKitCoordinator.hasPremiumAccess, !userHasRated else { return }
1167
+        SKStoreReviewController.requestReview()
1168
+        if markAsRated {
1169
+            UserDefaults.standard.set(true, forKey: userHasRatedDefaultsKey)
1170
+        }
1171
+    }
1172
+
1053
     private func refreshScheduleCardsForPremiumStateChange() {
1173
     private func refreshScheduleCardsForPremiumStateChange() {
1054
         guard let stack = scheduleCardsStack else { return }
1174
         guard let stack = scheduleCardsStack else { return }
1055
         renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
1175
         renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
@@ -1118,6 +1238,7 @@ private extension ViewController {
1118
             case .success:
1238
             case .success:
1119
                 self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
1239
                 self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
1120
                 self.paywallWindow?.performClose(nil)
1240
                 self.paywallWindow?.performClose(nil)
1241
+                self.scheduleRatingPromptAfterPremiumUpgrade()
1121
             case .cancelled:
1242
             case .cancelled:
1122
                 break
1243
                 break
1123
             case .pending:
1244
             case .pending:
@@ -3439,6 +3560,7 @@ private final class SettingsMenuViewController: NSViewController {
3439
         palette: Palette,
3560
         palette: Palette,
3440
         typography: Typography,
3561
         typography: Typography,
3441
         darkModeEnabled: Bool,
3562
         darkModeEnabled: Bool,
3563
+        showRateUsInSettings: Bool,
3442
         showUpgradeInSettings: Bool,
3564
         showUpgradeInSettings: Bool,
3443
         onToggleDarkMode: @escaping (Bool) -> Void,
3565
         onToggleDarkMode: @escaping (Bool) -> Void,
3444
         onAction: @escaping (SettingsAction) -> Void
3566
         onAction: @escaping (SettingsAction) -> Void
@@ -3448,7 +3570,11 @@ private final class SettingsMenuViewController: NSViewController {
3448
         self.onToggleDarkMode = onToggleDarkMode
3570
         self.onToggleDarkMode = onToggleDarkMode
3449
         self.onAction = onAction
3571
         self.onAction = onAction
3450
         super.init(nibName: nil, bundle: nil)
3572
         super.init(nibName: nil, bundle: nil)
3451
-        self.view = makeView(darkModeEnabled: darkModeEnabled, showUpgradeInSettings: showUpgradeInSettings)
3573
+        self.view = makeView(
3574
+            darkModeEnabled: darkModeEnabled,
3575
+            showRateUsInSettings: showRateUsInSettings,
3576
+            showUpgradeInSettings: showUpgradeInSettings
3577
+        )
3452
     }
3578
     }
3453
 
3579
 
3454
     @available(*, unavailable)
3580
     @available(*, unavailable)
@@ -3460,7 +3586,11 @@ private final class SettingsMenuViewController: NSViewController {
3460
         darkToggle?.state = enabled ? .on : .off
3586
         darkToggle?.state = enabled ? .on : .off
3461
     }
3587
     }
3462
 
3588
 
3463
-    private func makeView(darkModeEnabled: Bool, showUpgradeInSettings: Bool) -> NSView {
3589
+    private func makeView(
3590
+        darkModeEnabled: Bool,
3591
+        showRateUsInSettings: Bool,
3592
+        showUpgradeInSettings: Bool
3593
+    ) -> NSView {
3464
         let root = NSView()
3594
         let root = NSView()
3465
         root.translatesAutoresizingMaskIntoConstraints = false
3595
         root.translatesAutoresizingMaskIntoConstraints = false
3466
 
3596
 
@@ -3489,7 +3619,9 @@ private final class SettingsMenuViewController: NSViewController {
3489
         ])
3619
         ])
3490
 
3620
 
3491
         stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
3621
         stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
3492
-        stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
3622
+        if showRateUsInSettings {
3623
+            stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
3624
+        }
3493
         stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
3625
         stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
3494
         stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
3626
         stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
3495
         if showUpgradeInSettings {
3627
         if showUpgradeInSettings {