|
|
@@ -81,6 +81,7 @@ private final class StoreKitCoordinator {
|
|
81
|
81
|
private(set) var productsByID: [String: Product] = [:]
|
|
82
|
82
|
private(set) var activeEntitlementProductIDs: Set<String> = []
|
|
83
|
83
|
private(set) var lastProductLoadError: String?
|
|
|
84
|
+ var onEntitlementsChanged: ((Bool) -> Void)?
|
|
84
|
85
|
|
|
85
|
86
|
var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
|
|
86
|
87
|
|
|
|
@@ -112,6 +113,7 @@ private final class StoreKitCoordinator {
|
|
112
|
113
|
}
|
|
113
|
114
|
|
|
114
|
115
|
func refreshEntitlements() async {
|
|
|
116
|
+ let previousHasPremiumAccess = hasPremiumAccess
|
|
115
|
117
|
var active = Set<String>()
|
|
116
|
118
|
for await entitlement in Transaction.currentEntitlements {
|
|
117
|
119
|
guard case .verified(let transaction) = entitlement else { continue }
|
|
|
@@ -130,6 +132,10 @@ private final class StoreKitCoordinator {
|
|
130
|
132
|
active.insert(productID)
|
|
131
|
133
|
}
|
|
132
|
134
|
activeEntitlementProductIDs = active
|
|
|
135
|
+ let newHasPremiumAccess = hasPremiumAccess
|
|
|
136
|
+ if newHasPremiumAccess != previousHasPremiumAccess {
|
|
|
137
|
+ onEntitlementsChanged?(newHasPremiumAccess)
|
|
|
138
|
+ }
|
|
133
|
139
|
}
|
|
134
|
140
|
|
|
135
|
141
|
func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
|
|
|
@@ -243,6 +249,12 @@ final class ViewController: NSViewController {
|
|
243
|
249
|
private weak var sidebarPremiumTitleLabel: NSTextField?
|
|
244
|
250
|
private weak var sidebarPremiumIconView: NSImageView?
|
|
245
|
251
|
private weak var sidebarPremiumButtonView: HoverTrackingView?
|
|
|
252
|
+ private weak var instantMeetCardView: HoverSurfaceView?
|
|
|
253
|
+ private weak var instantMeetTitleLabel: NSTextField?
|
|
|
254
|
+ private weak var instantMeetSubtitleLabel: NSTextField?
|
|
|
255
|
+ private weak var joinWithLinkCardView: HoverSurfaceView?
|
|
|
256
|
+ private weak var joinWithLinkTitleLabel: NSTextField?
|
|
|
257
|
+ private weak var joinMeetPrimaryButton: NSButton?
|
|
246
|
258
|
private weak var meetLinkField: NSTextField?
|
|
247
|
259
|
private weak var browseAddressField: NSTextField?
|
|
248
|
260
|
private var inAppBrowserWindowController: InAppBrowserWindowController?
|
|
|
@@ -257,6 +269,7 @@ final class ViewController: NSViewController {
|
|
257
|
269
|
private var hasCompletedInitialStoreKitSync = false
|
|
258
|
270
|
private var hasPresentedLaunchPaywall = false
|
|
259
|
271
|
private var hasViewAppearedOnce = false
|
|
|
272
|
+ private var lastKnownPremiumAccess = false
|
|
260
|
273
|
|
|
261
|
274
|
private enum ScheduleFilter: Int {
|
|
262
|
275
|
case all = 0
|
|
|
@@ -323,6 +336,10 @@ final class ViewController: NSViewController {
|
|
323
|
336
|
// Sync toggle + palette with current macOS appearance on launch.
|
|
324
|
337
|
darkModeEnabled = systemPrefersDarkMode()
|
|
325
|
338
|
palette = Palette(isDarkMode: darkModeEnabled)
|
|
|
339
|
+ storeKitCoordinator.onEntitlementsChanged = { [weak self] hasPremiumAccess in
|
|
|
340
|
+ guard let self else { return }
|
|
|
341
|
+ self.handlePremiumAccessChanged(hasPremiumAccess)
|
|
|
342
|
+ }
|
|
326
|
343
|
setupRootView()
|
|
327
|
344
|
buildMainLayout()
|
|
328
|
345
|
startStoreKit()
|
|
|
@@ -449,6 +466,10 @@ private extension ViewController {
|
|
449
|
466
|
}
|
|
450
|
467
|
|
|
451
|
468
|
@objc private func joinMeetClicked(_ sender: Any?) {
|
|
|
469
|
+ guard storeKitCoordinator.hasPremiumAccess else {
|
|
|
470
|
+ showPaywall()
|
|
|
471
|
+ return
|
|
|
472
|
+ }
|
|
452
|
473
|
let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
453
|
474
|
|
|
454
|
475
|
guard let url = normalizedMeetJoinURL(from: rawInput) else {
|
|
|
@@ -462,6 +483,14 @@ private extension ViewController {
|
|
462
|
483
|
openInDefaultBrowser(url: url)
|
|
463
|
484
|
}
|
|
464
|
485
|
|
|
|
486
|
+ @objc private func joinWithLinkCardClicked(_ sender: NSClickGestureRecognizer) {
|
|
|
487
|
+ guard storeKitCoordinator.hasPremiumAccess else {
|
|
|
488
|
+ showPaywall()
|
|
|
489
|
+ return
|
|
|
490
|
+ }
|
|
|
491
|
+ meetLinkField?.window?.makeFirstResponder(meetLinkField)
|
|
|
492
|
+ }
|
|
|
493
|
+
|
|
465
|
494
|
@objc private func cancelMeetJoinClicked(_ sender: Any?) {
|
|
466
|
495
|
meetLinkField?.stringValue = ""
|
|
467
|
496
|
}
|
|
|
@@ -496,6 +525,10 @@ private extension ViewController {
|
|
496
|
525
|
}
|
|
497
|
526
|
|
|
498
|
527
|
@objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
|
|
|
528
|
+ guard storeKitCoordinator.hasPremiumAccess else {
|
|
|
529
|
+ showPaywall()
|
|
|
530
|
+ return
|
|
|
531
|
+ }
|
|
499
|
532
|
guard let url = URL(string: "https://meet.google.com/new") else { return }
|
|
500
|
533
|
openInDefaultBrowser(url: url)
|
|
501
|
534
|
}
|
|
|
@@ -876,10 +909,39 @@ private extension ViewController {
|
|
876
|
909
|
label.stringValue = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
|
|
877
|
910
|
}
|
|
878
|
911
|
refreshSidebarPremiumButton()
|
|
|
912
|
+ refreshInstantMeetPremiumState()
|
|
879
|
913
|
updatePaywallPlanSelection()
|
|
880
|
914
|
updatePaywallContinueState(isLoading: false)
|
|
881
|
915
|
}
|
|
882
|
916
|
|
|
|
917
|
+ private func refreshInstantMeetPremiumState() {
|
|
|
918
|
+ let isPremium = storeKitCoordinator.hasPremiumAccess
|
|
|
919
|
+ instantMeetCardView?.alphaValue = isPremium ? 1.0 : 0.65
|
|
|
920
|
+ instantMeetTitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
|
|
|
921
|
+ instantMeetSubtitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
|
|
|
922
|
+ instantMeetCardView?.toolTip = isPremium ? nil : "Premium required. Click to open paywall."
|
|
|
923
|
+ instantMeetCardView?.onHoverChanged?(false)
|
|
|
924
|
+
|
|
|
925
|
+ joinWithLinkCardView?.alphaValue = isPremium ? 1.0 : 0.65
|
|
|
926
|
+ joinWithLinkTitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
|
|
|
927
|
+ meetLinkField?.isEditable = isPremium
|
|
|
928
|
+ meetLinkField?.isSelectable = isPremium
|
|
|
929
|
+ meetLinkField?.alphaValue = isPremium ? 1.0 : 0.75
|
|
|
930
|
+ // Keep button enabled so non-premium taps can still trigger paywall.
|
|
|
931
|
+ joinMeetPrimaryButton?.isEnabled = true
|
|
|
932
|
+ joinMeetPrimaryButton?.alphaValue = isPremium ? 1.0 : 0.80
|
|
|
933
|
+ joinWithLinkCardView?.toolTip = isPremium ? nil : "Premium required. Click to open paywall."
|
|
|
934
|
+ }
|
|
|
935
|
+
|
|
|
936
|
+ private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
|
|
|
937
|
+ let hadPremiumAccess = lastKnownPremiumAccess
|
|
|
938
|
+ lastKnownPremiumAccess = hasPremiumAccess
|
|
|
939
|
+ refreshPaywallStoreUI()
|
|
|
940
|
+ if hadPremiumAccess && !hasPremiumAccess {
|
|
|
941
|
+ showPaywall()
|
|
|
942
|
+ }
|
|
|
943
|
+ }
|
|
|
944
|
+
|
|
883
|
945
|
private func refreshSidebarPremiumButton() {
|
|
884
|
946
|
let isPremium = storeKitCoordinator.hasPremiumAccess
|
|
885
|
947
|
if isPremium {
|
|
|
@@ -1476,17 +1538,37 @@ private extension ViewController {
|
|
1476
|
1538
|
let baseColor = palette.sectionCard
|
|
1477
|
1539
|
let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
|
|
1478
|
1540
|
let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
|
|
1479
|
|
- instant.onHoverChanged = { hovering in
|
|
1480
|
|
- instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
1541
|
+ instant.onHoverChanged = { [weak self] hovering in
|
|
|
1542
|
+ guard let self else { return }
|
|
|
1543
|
+ if self.storeKitCoordinator.hasPremiumAccess {
|
|
|
1544
|
+ instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
1545
|
+ } else {
|
|
|
1546
|
+ let disabledColor = self.palette.sectionCard.blended(withFraction: 0.22, of: self.palette.pageBackground) ?? self.palette.sectionCard
|
|
|
1547
|
+ instant.layer?.backgroundColor = disabledColor.cgColor
|
|
|
1548
|
+ }
|
|
1481
|
1549
|
}
|
|
1482
|
|
- codeCard.onHoverChanged = { hovering in
|
|
1483
|
|
- codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
1550
|
+ codeCard.onHoverChanged = { [weak self] hovering in
|
|
|
1551
|
+ guard let self else { return }
|
|
|
1552
|
+ if self.storeKitCoordinator.hasPremiumAccess {
|
|
|
1553
|
+ codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
1554
|
+ } else {
|
|
|
1555
|
+ let disabledColor = self.palette.sectionCard.blended(withFraction: 0.22, of: self.palette.pageBackground) ?? self.palette.sectionCard
|
|
|
1556
|
+ codeCard.layer?.backgroundColor = disabledColor.cgColor
|
|
|
1557
|
+ }
|
|
1484
|
1558
|
}
|
|
1485
|
1559
|
instant.onHoverChanged?(false)
|
|
1486
|
1560
|
codeCard.onHoverChanged?(false)
|
|
1487
|
1561
|
|
|
1488
|
1562
|
let instantClick = NSClickGestureRecognizer(target: self, action: #selector(instantMeetClicked(_:)))
|
|
1489
|
1563
|
instant.addGestureRecognizer(instantClick)
|
|
|
1564
|
+ let joinWithLinkClick = NSClickGestureRecognizer(target: self, action: #selector(joinWithLinkCardClicked(_:)))
|
|
|
1565
|
+ codeCard.addGestureRecognizer(joinWithLinkClick)
|
|
|
1566
|
+ instantMeetCardView = instant
|
|
|
1567
|
+ instantMeetTitleLabel = instantTitle
|
|
|
1568
|
+ instantMeetSubtitleLabel = instantSub
|
|
|
1569
|
+ joinWithLinkCardView = codeCard
|
|
|
1570
|
+ joinWithLinkTitleLabel = codeTitle
|
|
|
1571
|
+ refreshInstantMeetPremiumState()
|
|
1490
|
1572
|
|
|
1491
|
1573
|
row.addArrangedSubview(instant)
|
|
1492
|
1574
|
row.addArrangedSubview(codeCard)
|
|
|
@@ -1511,13 +1593,16 @@ private extension ViewController {
|
|
1511
|
1593
|
width: 110,
|
|
1512
|
1594
|
action: #selector(cancelMeetJoinClicked(_:))
|
|
1513
|
1595
|
))
|
|
1514
|
|
- row.addArrangedSubview(meetActionButton(
|
|
|
1596
|
+ let joinButton = meetActionButton(
|
|
1515
|
1597
|
title: "Join",
|
|
1516
|
1598
|
color: palette.primaryBlue,
|
|
1517
|
1599
|
textColor: .white,
|
|
1518
|
1600
|
width: 116,
|
|
1519
|
1601
|
action: #selector(joinMeetClicked(_:))
|
|
1520
|
|
- ))
|
|
|
1602
|
+ )
|
|
|
1603
|
+ joinMeetPrimaryButton = joinButton
|
|
|
1604
|
+ row.addArrangedSubview(joinButton)
|
|
|
1605
|
+ refreshInstantMeetPremiumState()
|
|
1521
|
1606
|
return row
|
|
1522
|
1607
|
}
|
|
1523
|
1608
|
|
|
|
@@ -2552,10 +2637,43 @@ private extension ViewController {
|
|
2552
|
2637
|
let base = self.palette.sectionCard
|
|
2553
|
2638
|
let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
|
|
2554
|
2639
|
let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
|
|
2555
|
|
- card.layer?.backgroundColor = (hovering ? hover : base).cgColor
|
|
|
2640
|
+ if self.storeKitCoordinator.hasPremiumAccess {
|
|
|
2641
|
+ card.layer?.backgroundColor = (hovering ? hover : base).cgColor
|
|
|
2642
|
+ } else {
|
|
|
2643
|
+ card.layer?.backgroundColor = base.cgColor
|
|
|
2644
|
+ }
|
|
2556
|
2645
|
}
|
|
2557
|
2646
|
hit.onHoverChanged?(false)
|
|
2558
|
2647
|
|
|
|
2648
|
+ if !storeKitCoordinator.hasPremiumAccess {
|
|
|
2649
|
+ let lockOverlay = NSVisualEffectView()
|
|
|
2650
|
+ lockOverlay.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2651
|
+ lockOverlay.material = darkModeEnabled ? .hudWindow : .popover
|
|
|
2652
|
+ lockOverlay.blendingMode = .withinWindow
|
|
|
2653
|
+ lockOverlay.state = .active
|
|
|
2654
|
+ lockOverlay.wantsLayer = true
|
|
|
2655
|
+ lockOverlay.layer?.cornerRadius = 12
|
|
|
2656
|
+ lockOverlay.layer?.masksToBounds = true
|
|
|
2657
|
+ lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.28 : 0.12).cgColor
|
|
|
2658
|
+
|
|
|
2659
|
+ let lockLabel = textLabel("Get Premium to see events", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .white)
|
|
|
2660
|
+ lockLabel.alignment = .center
|
|
|
2661
|
+
|
|
|
2662
|
+ card.addSubview(lockOverlay)
|
|
|
2663
|
+ lockOverlay.addSubview(lockLabel)
|
|
|
2664
|
+ NSLayoutConstraint.activate([
|
|
|
2665
|
+ lockOverlay.leadingAnchor.constraint(equalTo: card.leadingAnchor),
|
|
|
2666
|
+ lockOverlay.trailingAnchor.constraint(equalTo: card.trailingAnchor),
|
|
|
2667
|
+ lockOverlay.topAnchor.constraint(equalTo: card.topAnchor),
|
|
|
2668
|
+ lockOverlay.bottomAnchor.constraint(equalTo: card.bottomAnchor),
|
|
|
2669
|
+ lockLabel.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor),
|
|
|
2670
|
+ lockLabel.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor),
|
|
|
2671
|
+ lockLabel.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 10),
|
|
|
2672
|
+ lockLabel.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -10)
|
|
|
2673
|
+ ])
|
|
|
2674
|
+ hit.toolTip = "Premium required. Click to open paywall."
|
|
|
2675
|
+ }
|
|
|
2676
|
+
|
|
2559
|
2677
|
return hit
|
|
2560
|
2678
|
}
|
|
2561
|
2679
|
|
|
|
@@ -3489,6 +3607,10 @@ private extension ViewController {
|
|
3489
|
3607
|
}
|
|
3490
|
3608
|
|
|
3491
|
3609
|
@objc func scheduleCardButtonPressed(_ sender: NSButton) {
|
|
|
3610
|
+ guard storeKitCoordinator.hasPremiumAccess else {
|
|
|
3611
|
+ showPaywall()
|
|
|
3612
|
+ return
|
|
|
3613
|
+ }
|
|
3492
|
3614
|
guard let raw = sender.identifier?.rawValue,
|
|
3493
|
3615
|
let url = URL(string: raw) else { return }
|
|
3494
|
3616
|
openMeetingURL(url)
|