|
|
@@ -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 {
|