|
|
@@ -283,6 +283,9 @@ final class ViewController: NSViewController {
|
|
|
private var hasViewAppearedOnce = false
|
|
|
private var lastKnownPremiumAccess = false
|
|
|
private var displayedScheduleMeetings: [ScheduledMeeting] = []
|
|
|
+ private var appUsageSessionStartDate: Date?
|
|
|
+ private var hasObservedAppLifecycleForUsage = false
|
|
|
+ private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
|
|
|
|
|
|
private enum ScheduleFilter: Int {
|
|
|
case all = 0
|
|
|
@@ -316,6 +319,10 @@ final class ViewController: NSViewController {
|
|
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
|
|
|
|
private let darkModeDefaultsKey = "settings.darkModeEnabled"
|
|
|
+ private let appUsageAccumulatedSecondsDefaultsKey = "rating.appUsageAccumulatedSeconds"
|
|
|
+ private let userHasRatedDefaultsKey = "rating.userHasRated"
|
|
|
+ private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
|
|
|
+ private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
|
|
|
private var darkModeEnabled: Bool {
|
|
|
get {
|
|
|
let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
|
|
|
@@ -329,10 +336,12 @@ final class ViewController: NSViewController {
|
|
|
popover.behavior = .transient
|
|
|
popover.animates = true
|
|
|
let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
|
|
|
+ let showRateUsInSettings = shouldShowRateUsInSettings
|
|
|
popover.contentViewController = SettingsMenuViewController(
|
|
|
palette: palette,
|
|
|
typography: typography,
|
|
|
darkModeEnabled: darkModeEnabled,
|
|
|
+ showRateUsInSettings: showRateUsInSettings,
|
|
|
showUpgradeInSettings: showUpgradeInSettings,
|
|
|
onToggleDarkMode: { [weak self] enabled in
|
|
|
self?.setDarkMode(enabled)
|
|
|
@@ -355,6 +364,9 @@ final class ViewController: NSViewController {
|
|
|
guard let self else { return }
|
|
|
self.handlePremiumAccessChanged(hasPremiumAccess)
|
|
|
}
|
|
|
+ migrateLegacyRatingStateIfNeeded()
|
|
|
+ beginUsageTrackingSessionIfNeeded()
|
|
|
+ observeAppLifecycleForUsageTrackingIfNeeded()
|
|
|
setupRootView()
|
|
|
buildMainLayout()
|
|
|
startStoreKit()
|
|
|
@@ -397,6 +409,11 @@ final class ViewController: NSViewController {
|
|
|
}
|
|
|
|
|
|
deinit {
|
|
|
+ premiumUpgradeRatingPromptWorkItem?.cancel()
|
|
|
+ endUsageTrackingSession()
|
|
|
+ if hasObservedAppLifecycleForUsage {
|
|
|
+ NotificationCenter.default.removeObserver(self)
|
|
|
+ }
|
|
|
storeKitStartupTask?.cancel()
|
|
|
paywallPurchaseTask?.cancel()
|
|
|
launchPaywallWorkItem?.cancel()
|
|
|
@@ -764,10 +781,7 @@ private extension ViewController {
|
|
|
case .rateUs:
|
|
|
settingsPopover?.performClose(nil)
|
|
|
settingsPopover = nil
|
|
|
- // Replace with your App Store product URL when the app is listed.
|
|
|
- if let url = URL(string: "https://apps.apple.com/app/id0000000000") {
|
|
|
- openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
|
|
|
- }
|
|
|
+ requestAppRatingIfEligible(markAsRated: true)
|
|
|
case .support:
|
|
|
settingsPopover?.performClose(nil)
|
|
|
settingsPopover = nil
|
|
|
@@ -1038,18 +1052,124 @@ private extension ViewController {
|
|
|
private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
|
|
|
let hadPremiumAccess = lastKnownPremiumAccess
|
|
|
lastKnownPremiumAccess = hasPremiumAccess
|
|
|
+ premiumUpgradeRatingPromptWorkItem?.cancel()
|
|
|
refreshPaywallStoreUI()
|
|
|
refreshScheduleCardsForPremiumStateChange()
|
|
|
if !hadPremiumAccess && hasPremiumAccess {
|
|
|
Task { [weak self] in
|
|
|
await self?.loadSchedule()
|
|
|
}
|
|
|
+ scheduleRatingPromptAfterPremiumUpgrade()
|
|
|
}
|
|
|
if hadPremiumAccess && !hasPremiumAccess {
|
|
|
showPaywall()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private var userHasRated: Bool {
|
|
|
+ UserDefaults.standard.bool(forKey: userHasRatedDefaultsKey)
|
|
|
+ }
|
|
|
+
|
|
|
+ private var accumulatedAppUsageSeconds: TimeInterval {
|
|
|
+ get {
|
|
|
+ UserDefaults.standard.double(forKey: appUsageAccumulatedSecondsDefaultsKey)
|
|
|
+ }
|
|
|
+ set {
|
|
|
+ UserDefaults.standard.set(newValue, forKey: appUsageAccumulatedSecondsDefaultsKey)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var totalTrackedUsageSeconds: TimeInterval {
|
|
|
+ let liveSessionSeconds: TimeInterval
|
|
|
+ if let start = appUsageSessionStartDate {
|
|
|
+ liveSessionSeconds = max(0, Date().timeIntervalSince(start))
|
|
|
+ } else {
|
|
|
+ liveSessionSeconds = 0
|
|
|
+ }
|
|
|
+ return accumulatedAppUsageSeconds + liveSessionSeconds
|
|
|
+ }
|
|
|
+
|
|
|
+ private var hasReachedRatingUsageThreshold: Bool {
|
|
|
+ totalTrackedUsageSeconds >= ratingEligibleUsageSeconds
|
|
|
+ }
|
|
|
+
|
|
|
+ private var shouldShowRateUsInSettings: Bool {
|
|
|
+ storeKitCoordinator.hasPremiumAccess && !userHasRated && hasReachedRatingUsageThreshold
|
|
|
+ }
|
|
|
+
|
|
|
+ private func migrateLegacyRatingStateIfNeeded() {
|
|
|
+ let defaults = UserDefaults.standard
|
|
|
+ guard !defaults.bool(forKey: ratingStateMigrationV2DoneDefaultsKey) else { return }
|
|
|
+ // Legacy behavior marked "rated" immediately after requesting review.
|
|
|
+ // Clear once so testing and new logic can run correctly.
|
|
|
+ defaults.set(false, forKey: userHasRatedDefaultsKey)
|
|
|
+ defaults.set(true, forKey: ratingStateMigrationV2DoneDefaultsKey)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func beginUsageTrackingSessionIfNeeded() {
|
|
|
+ guard appUsageSessionStartDate == nil else { return }
|
|
|
+ appUsageSessionStartDate = Date()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func endUsageTrackingSession() {
|
|
|
+ guard let start = appUsageSessionStartDate else { return }
|
|
|
+ let sessionElapsedSeconds = max(0, Date().timeIntervalSince(start))
|
|
|
+ accumulatedAppUsageSeconds += sessionElapsedSeconds
|
|
|
+ appUsageSessionStartDate = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private func observeAppLifecycleForUsageTrackingIfNeeded() {
|
|
|
+ guard !hasObservedAppLifecycleForUsage else { return }
|
|
|
+ hasObservedAppLifecycleForUsage = true
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
+ self,
|
|
|
+ selector: #selector(applicationDidBecomeActiveForUsageTracking),
|
|
|
+ name: NSApplication.didBecomeActiveNotification,
|
|
|
+ object: nil
|
|
|
+ )
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
+ self,
|
|
|
+ selector: #selector(applicationWillResignActiveForUsageTracking),
|
|
|
+ name: NSApplication.willResignActiveNotification,
|
|
|
+ object: nil
|
|
|
+ )
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
+ self,
|
|
|
+ selector: #selector(applicationWillTerminateForUsageTracking),
|
|
|
+ name: NSApplication.willTerminateNotification,
|
|
|
+ object: nil
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func applicationDidBecomeActiveForUsageTracking() {
|
|
|
+ beginUsageTrackingSessionIfNeeded()
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func applicationWillResignActiveForUsageTracking() {
|
|
|
+ endUsageTrackingSession()
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func applicationWillTerminateForUsageTracking() {
|
|
|
+ endUsageTrackingSession()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func scheduleRatingPromptAfterPremiumUpgrade() {
|
|
|
+ guard !userHasRated else { return }
|
|
|
+ let workItem = DispatchWorkItem { [weak self] in
|
|
|
+ self?.requestAppRatingIfEligible(markAsRated: false)
|
|
|
+ }
|
|
|
+ premiumUpgradeRatingPromptWorkItem = workItem
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: workItem)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func requestAppRatingIfEligible(markAsRated: Bool) {
|
|
|
+ guard storeKitCoordinator.hasPremiumAccess, !userHasRated else { return }
|
|
|
+ SKStoreReviewController.requestReview()
|
|
|
+ if markAsRated {
|
|
|
+ UserDefaults.standard.set(true, forKey: userHasRatedDefaultsKey)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private func refreshScheduleCardsForPremiumStateChange() {
|
|
|
guard let stack = scheduleCardsStack else { return }
|
|
|
renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
|
|
|
@@ -1118,6 +1238,7 @@ private extension ViewController {
|
|
|
case .success:
|
|
|
self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
|
|
|
self.paywallWindow?.performClose(nil)
|
|
|
+ self.scheduleRatingPromptAfterPremiumUpgrade()
|
|
|
case .cancelled:
|
|
|
break
|
|
|
case .pending:
|
|
|
@@ -3439,6 +3560,7 @@ private final class SettingsMenuViewController: NSViewController {
|
|
|
palette: Palette,
|
|
|
typography: Typography,
|
|
|
darkModeEnabled: Bool,
|
|
|
+ showRateUsInSettings: Bool,
|
|
|
showUpgradeInSettings: Bool,
|
|
|
onToggleDarkMode: @escaping (Bool) -> Void,
|
|
|
onAction: @escaping (SettingsAction) -> Void
|
|
|
@@ -3448,7 +3570,11 @@ private final class SettingsMenuViewController: NSViewController {
|
|
|
self.onToggleDarkMode = onToggleDarkMode
|
|
|
self.onAction = onAction
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
- self.view = makeView(darkModeEnabled: darkModeEnabled, showUpgradeInSettings: showUpgradeInSettings)
|
|
|
+ self.view = makeView(
|
|
|
+ darkModeEnabled: darkModeEnabled,
|
|
|
+ showRateUsInSettings: showRateUsInSettings,
|
|
|
+ showUpgradeInSettings: showUpgradeInSettings
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
@available(*, unavailable)
|
|
|
@@ -3460,7 +3586,11 @@ private final class SettingsMenuViewController: NSViewController {
|
|
|
darkToggle?.state = enabled ? .on : .off
|
|
|
}
|
|
|
|
|
|
- private func makeView(darkModeEnabled: Bool, showUpgradeInSettings: Bool) -> NSView {
|
|
|
+ private func makeView(
|
|
|
+ darkModeEnabled: Bool,
|
|
|
+ showRateUsInSettings: Bool,
|
|
|
+ showUpgradeInSettings: Bool
|
|
|
+ ) -> NSView {
|
|
|
let root = NSView()
|
|
|
root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
@@ -3489,7 +3619,9 @@ private final class SettingsMenuViewController: NSViewController {
|
|
|
])
|
|
|
|
|
|
stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
|
|
|
- stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
|
|
|
+ if showRateUsInSettings {
|
|
|
+ stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
|
|
|
+ }
|
|
|
stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
|
|
|
stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
|
|
|
if showUpgradeInSettings {
|