Quellcode durchsuchen

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 vor 1 Woche
Ursprung
Commit
b75ddc0f46
1 geänderte Dateien mit 139 neuen und 7 gelöschten Zeilen
  1. 139 7
      meetings_app/ViewController.swift

+ 139 - 7
meetings_app/ViewController.swift

@@ -283,6 +283,9 @@ final class ViewController: NSViewController {
283 283
     private var hasViewAppearedOnce = false
284 284
     private var lastKnownPremiumAccess = false
285 285
     private var displayedScheduleMeetings: [ScheduledMeeting] = []
286
+    private var appUsageSessionStartDate: Date?
287
+    private var hasObservedAppLifecycleForUsage = false
288
+    private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
286 289
 
287 290
     private enum ScheduleFilter: Int {
288 291
         case all = 0
@@ -316,6 +319,10 @@ final class ViewController: NSViewController {
316 319
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
317 320
 
318 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 326
     private var darkModeEnabled: Bool {
320 327
         get {
321 328
             let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
@@ -329,10 +336,12 @@ final class ViewController: NSViewController {
329 336
         popover.behavior = .transient
330 337
         popover.animates = true
331 338
         let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
339
+        let showRateUsInSettings = shouldShowRateUsInSettings
332 340
         popover.contentViewController = SettingsMenuViewController(
333 341
             palette: palette,
334 342
             typography: typography,
335 343
             darkModeEnabled: darkModeEnabled,
344
+            showRateUsInSettings: showRateUsInSettings,
336 345
             showUpgradeInSettings: showUpgradeInSettings,
337 346
             onToggleDarkMode: { [weak self] enabled in
338 347
                 self?.setDarkMode(enabled)
@@ -355,6 +364,9 @@ final class ViewController: NSViewController {
355 364
             guard let self else { return }
356 365
             self.handlePremiumAccessChanged(hasPremiumAccess)
357 366
         }
367
+        migrateLegacyRatingStateIfNeeded()
368
+        beginUsageTrackingSessionIfNeeded()
369
+        observeAppLifecycleForUsageTrackingIfNeeded()
358 370
         setupRootView()
359 371
         buildMainLayout()
360 372
         startStoreKit()
@@ -397,6 +409,11 @@ final class ViewController: NSViewController {
397 409
     }
398 410
 
399 411
     deinit {
412
+        premiumUpgradeRatingPromptWorkItem?.cancel()
413
+        endUsageTrackingSession()
414
+        if hasObservedAppLifecycleForUsage {
415
+            NotificationCenter.default.removeObserver(self)
416
+        }
400 417
         storeKitStartupTask?.cancel()
401 418
         paywallPurchaseTask?.cancel()
402 419
         launchPaywallWorkItem?.cancel()
@@ -764,10 +781,7 @@ private extension ViewController {
764 781
         case .rateUs:
765 782
             settingsPopover?.performClose(nil)
766 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 785
         case .support:
772 786
             settingsPopover?.performClose(nil)
773 787
             settingsPopover = nil
@@ -1038,18 +1052,124 @@ private extension ViewController {
1038 1052
     private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
1039 1053
         let hadPremiumAccess = lastKnownPremiumAccess
1040 1054
         lastKnownPremiumAccess = hasPremiumAccess
1055
+        premiumUpgradeRatingPromptWorkItem?.cancel()
1041 1056
         refreshPaywallStoreUI()
1042 1057
         refreshScheduleCardsForPremiumStateChange()
1043 1058
         if !hadPremiumAccess && hasPremiumAccess {
1044 1059
             Task { [weak self] in
1045 1060
                 await self?.loadSchedule()
1046 1061
             }
1062
+            scheduleRatingPromptAfterPremiumUpgrade()
1047 1063
         }
1048 1064
         if hadPremiumAccess && !hasPremiumAccess {
1049 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 1173
     private func refreshScheduleCardsForPremiumStateChange() {
1054 1174
         guard let stack = scheduleCardsStack else { return }
1055 1175
         renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
@@ -1118,6 +1238,7 @@ private extension ViewController {
1118 1238
             case .success:
1119 1239
                 self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
1120 1240
                 self.paywallWindow?.performClose(nil)
1241
+                self.scheduleRatingPromptAfterPremiumUpgrade()
1121 1242
             case .cancelled:
1122 1243
                 break
1123 1244
             case .pending:
@@ -3439,6 +3560,7 @@ private final class SettingsMenuViewController: NSViewController {
3439 3560
         palette: Palette,
3440 3561
         typography: Typography,
3441 3562
         darkModeEnabled: Bool,
3563
+        showRateUsInSettings: Bool,
3442 3564
         showUpgradeInSettings: Bool,
3443 3565
         onToggleDarkMode: @escaping (Bool) -> Void,
3444 3566
         onAction: @escaping (SettingsAction) -> Void
@@ -3448,7 +3570,11 @@ private final class SettingsMenuViewController: NSViewController {
3448 3570
         self.onToggleDarkMode = onToggleDarkMode
3449 3571
         self.onAction = onAction
3450 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 3580
     @available(*, unavailable)
@@ -3460,7 +3586,11 @@ private final class SettingsMenuViewController: NSViewController {
3460 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 3594
         let root = NSView()
3465 3595
         root.translatesAutoresizingMaskIntoConstraints = false
3466 3596
 
@@ -3489,7 +3619,9 @@ private final class SettingsMenuViewController: NSViewController {
3489 3619
         ])
3490 3620
 
3491 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 3625
         stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
3494 3626
         stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
3495 3627
         if showUpgradeInSettings {