|
|
@@ -96,6 +96,7 @@ class ViewController: NSViewController {
|
|
96
|
96
|
private final class HoverButton: NSButton {
|
|
97
|
97
|
var normalColor: NSColor = .clear { didSet { applyBackground() } }
|
|
98
|
98
|
var hoverColor: NSColor = .clear
|
|
|
99
|
+ var onHoverChanged: ((Bool) -> Void)?
|
|
99
|
100
|
private var tracking: NSTrackingArea?
|
|
100
|
101
|
private var hovering = false { didSet { applyBackground() } }
|
|
101
|
102
|
|
|
|
@@ -109,10 +110,12 @@ class ViewController: NSViewController {
|
|
109
|
110
|
|
|
110
|
111
|
override func mouseEntered(with event: NSEvent) {
|
|
111
|
112
|
hovering = true
|
|
|
113
|
+ onHoverChanged?(true)
|
|
112
|
114
|
}
|
|
113
|
115
|
|
|
114
|
116
|
override func mouseExited(with event: NSEvent) {
|
|
115
|
117
|
hovering = false
|
|
|
118
|
+ onHoverChanged?(false)
|
|
116
|
119
|
}
|
|
117
|
120
|
|
|
118
|
121
|
private func applyBackground() {
|
|
|
@@ -183,6 +186,17 @@ class ViewController: NSViewController {
|
|
183
|
186
|
private weak var settingsUpgradeButton: NSButton?
|
|
184
|
187
|
private weak var settingsRestoreButton: NSButton?
|
|
185
|
188
|
private weak var settingsGoogleActionButton: NSButton?
|
|
|
189
|
+ private weak var topBarPremiumButton: NSButton?
|
|
|
190
|
+ private var paywallWindow: NSWindow?
|
|
|
191
|
+ private let paywallContentWidth: CGFloat = 520
|
|
|
192
|
+ private var selectedPremiumPlan: PremiumPlan = .monthly
|
|
|
193
|
+ private var paywallPlanViews: [PremiumPlan: NSView] = [:]
|
|
|
194
|
+ private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
|
|
|
195
|
+ private weak var paywallOfferLabel: NSTextField?
|
|
|
196
|
+ private weak var paywallContinueLabel: NSTextField?
|
|
|
197
|
+ private weak var paywallContinueButton: NSView?
|
|
|
198
|
+ private var paywallPurchaseTask: Task<Void, Never>?
|
|
|
199
|
+ private var paywallContinueEnabled = true
|
|
186
|
200
|
private var allScheduledMeetings: [ScheduledMeeting] = []
|
|
187
|
201
|
private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
|
|
188
|
202
|
private var selectedHomeSidebarItem: String = "Home"
|
|
|
@@ -314,20 +328,41 @@ class ViewController: NSViewController {
|
|
314
|
328
|
|
|
315
|
329
|
@MainActor
|
|
316
|
330
|
private func refreshEntitlements() async {
|
|
|
331
|
+ let previousHasPremiumAccess = hasPremiumAccess
|
|
|
332
|
+ let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
|
|
317
|
333
|
var active = Set<String>()
|
|
|
334
|
+
|
|
318
|
335
|
for await entitlement in Transaction.currentEntitlements {
|
|
319
|
336
|
guard case .verified(let transaction) = entitlement else { continue }
|
|
320
|
|
- if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID),
|
|
321
|
|
- transaction.revocationDate == nil {
|
|
|
337
|
+ guard allIDs.contains(transaction.productID) else { continue }
|
|
|
338
|
+ if Self.isTransactionActive(transaction) {
|
|
322
|
339
|
active.insert(transaction.productID)
|
|
323
|
340
|
}
|
|
324
|
341
|
}
|
|
325
|
|
- let changed = active != activeProductIDs
|
|
|
342
|
+
|
|
|
343
|
+ // StoreKit testing can briefly report empty current entitlements even though a latest
|
|
|
344
|
+ // verified transaction exists for a non-consumable. Merge in latest transactions.
|
|
|
345
|
+ for productID in allIDs {
|
|
|
346
|
+ guard let latest = await Transaction.latest(for: productID),
|
|
|
347
|
+ case .verified(let transaction) = latest,
|
|
|
348
|
+ Self.isTransactionActive(transaction) else { continue }
|
|
|
349
|
+ active.insert(productID)
|
|
|
350
|
+ }
|
|
|
351
|
+
|
|
326
|
352
|
activeProductIDs = active
|
|
327
|
|
- if changed {
|
|
328
|
|
- onEntitlementsChanged?(hasPremiumAccess)
|
|
|
353
|
+ let newHasPremiumAccess = hasPremiumAccess
|
|
|
354
|
+ if newHasPremiumAccess != previousHasPremiumAccess {
|
|
|
355
|
+ onEntitlementsChanged?(newHasPremiumAccess)
|
|
329
|
356
|
}
|
|
330
|
357
|
}
|
|
|
358
|
+
|
|
|
359
|
+ private static func isTransactionActive(_ transaction: Transaction) -> Bool {
|
|
|
360
|
+ if transaction.revocationDate != nil { return false }
|
|
|
361
|
+ if let expirationDate = transaction.expirationDate {
|
|
|
362
|
+ return expirationDate > Date()
|
|
|
363
|
+ }
|
|
|
364
|
+ return true
|
|
|
365
|
+ }
|
|
331
|
366
|
}
|
|
332
|
367
|
|
|
333
|
368
|
private let storeKitCoordinator = StoreKitCoordinator()
|
|
|
@@ -346,7 +381,9 @@ class ViewController: NSViewController {
|
|
346
|
381
|
super.viewDidAppear()
|
|
347
|
382
|
if let window = view.window {
|
|
348
|
383
|
window.setContentSize(NSSize(width: 1020, height: 690))
|
|
349
|
|
- window.backgroundColor = chromeUnifiedBackground
|
|
|
384
|
+ // Match the outer window background to the app background so rounded shell corners
|
|
|
385
|
+ // don't reveal a lighter window color in Dark Mode.
|
|
|
386
|
+ window.backgroundColor = appBackground
|
|
350
|
387
|
// Use full-size content view so custom top chrome sits in the titlebar region.
|
|
351
|
388
|
window.titleVisibility = .hidden
|
|
352
|
389
|
window.titlebarAppearsTransparent = true
|
|
|
@@ -528,6 +565,20 @@ class ViewController: NSViewController {
|
|
528
|
565
|
// Reserved for future titlebar control actions.
|
|
529
|
566
|
}
|
|
530
|
567
|
|
|
|
568
|
+ @objc private func upgradeToProTapped() {
|
|
|
569
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
570
|
+ openManageSubscriptions()
|
|
|
571
|
+ } else {
|
|
|
572
|
+ showPaywall()
|
|
|
573
|
+ }
|
|
|
574
|
+ }
|
|
|
575
|
+
|
|
|
576
|
+ private func openManageSubscriptions() {
|
|
|
577
|
+ if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
|
|
|
578
|
+ NSWorkspace.shared.open(url)
|
|
|
579
|
+ }
|
|
|
580
|
+ }
|
|
|
581
|
+
|
|
531
|
582
|
@objc private func refreshMeetingsTapped() {
|
|
532
|
583
|
Task { @MainActor in
|
|
533
|
584
|
self.animateMeetingsRefresh()
|
|
|
@@ -1487,6 +1538,44 @@ class ViewController: NSViewController {
|
|
1487
|
1538
|
settingsUpgradeButton?.isEnabled = isPremium == false
|
|
1488
|
1539
|
settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
|
|
1489
|
1540
|
settingsRestoreButton?.isEnabled = true
|
|
|
1541
|
+
|
|
|
1542
|
+ updateTopBarPremiumButton()
|
|
|
1543
|
+ }
|
|
|
1544
|
+
|
|
|
1545
|
+ @MainActor
|
|
|
1546
|
+ private func updateTopBarPremiumButton() {
|
|
|
1547
|
+ guard let button = topBarPremiumButton else { return }
|
|
|
1548
|
+ let isPremium = storeKitCoordinator.hasPremiumAccess
|
|
|
1549
|
+ if isPremium {
|
|
|
1550
|
+ let title = "Manage Subscription"
|
|
|
1551
|
+ let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
1552
|
+ button.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
|
1553
|
+ .foregroundColor: NSColor.white,
|
|
|
1554
|
+ .font: font
|
|
|
1555
|
+ ])
|
|
|
1556
|
+ let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
|
|
|
1557
|
+ if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
|
|
|
1558
|
+ let img = base.withSymbolConfiguration(symbolConfig) {
|
|
|
1559
|
+ img.isTemplate = true
|
|
|
1560
|
+ button.image = img
|
|
|
1561
|
+ button.imagePosition = .imageLeading
|
|
|
1562
|
+ button.imageHugsTitle = true
|
|
|
1563
|
+ }
|
|
|
1564
|
+ button.contentTintColor = NSColor.white
|
|
|
1565
|
+ button.toolTip = title
|
|
|
1566
|
+ button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
|
|
|
1567
|
+ } else {
|
|
|
1568
|
+ let title = "Upgrade to Pro"
|
|
|
1569
|
+ let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
1570
|
+ button.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
|
1571
|
+ .foregroundColor: NSColor.white,
|
|
|
1572
|
+ .font: font
|
|
|
1573
|
+ ])
|
|
|
1574
|
+ button.image = nil
|
|
|
1575
|
+ button.toolTip = title
|
|
|
1576
|
+ button.layer?.backgroundColor = accentBlue.cgColor
|
|
|
1577
|
+ button.contentTintColor = .white
|
|
|
1578
|
+ }
|
|
1490
|
1579
|
}
|
|
1491
|
1580
|
|
|
1492
|
1581
|
@objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
|
|
|
@@ -1548,6 +1637,481 @@ class ViewController: NSViewController {
|
|
1548
|
1637
|
}
|
|
1549
|
1638
|
}
|
|
1550
|
1639
|
|
|
|
1640
|
+ // MARK: - Paywall (ported from meetings_app)
|
|
|
1641
|
+
|
|
|
1642
|
+ private func showPaywall() {
|
|
|
1643
|
+ if let existing = paywallWindow {
|
|
|
1644
|
+ refreshPaywallStoreUI()
|
|
|
1645
|
+ existing.makeKeyAndOrderFront(nil)
|
|
|
1646
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
1647
|
+ return
|
|
|
1648
|
+ }
|
|
|
1649
|
+
|
|
|
1650
|
+ let content = makePaywallContent()
|
|
|
1651
|
+ let controller = NSViewController()
|
|
|
1652
|
+ controller.view = content
|
|
|
1653
|
+
|
|
|
1654
|
+ let panel = NSPanel(
|
|
|
1655
|
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
|
|
|
1656
|
+ styleMask: [.titled, .closable, .fullSizeContentView],
|
|
|
1657
|
+ backing: .buffered,
|
|
|
1658
|
+ defer: false
|
|
|
1659
|
+ )
|
|
|
1660
|
+ panel.title = "Get Premium"
|
|
|
1661
|
+ panel.titleVisibility = .hidden
|
|
|
1662
|
+ panel.titlebarAppearsTransparent = true
|
|
|
1663
|
+ panel.hidesOnDeactivate = true
|
|
|
1664
|
+ panel.isReleasedWhenClosed = false
|
|
|
1665
|
+ panel.standardWindowButton(.closeButton)?.isHidden = true
|
|
|
1666
|
+ panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
|
1667
|
+ panel.standardWindowButton(.zoomButton)?.isHidden = true
|
|
|
1668
|
+ panel.center()
|
|
|
1669
|
+ panel.contentViewController = controller
|
|
|
1670
|
+ panel.makeKeyAndOrderFront(nil)
|
|
|
1671
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
1672
|
+ paywallWindow = panel
|
|
|
1673
|
+
|
|
|
1674
|
+ Task { [weak self] in
|
|
|
1675
|
+ guard let self else { return }
|
|
|
1676
|
+ await self.storeKitCoordinator.refreshProducts()
|
|
|
1677
|
+ await MainActor.run {
|
|
|
1678
|
+ self.refreshPaywallStoreUI()
|
|
|
1679
|
+ }
|
|
|
1680
|
+ }
|
|
|
1681
|
+ }
|
|
|
1682
|
+
|
|
|
1683
|
+ @objc private func closePaywallClicked(_ sender: Any?) {
|
|
|
1684
|
+ paywallWindow?.performClose(nil)
|
|
|
1685
|
+ paywallWindow = nil
|
|
|
1686
|
+ }
|
|
|
1687
|
+
|
|
|
1688
|
+ private func makePaywallContent() -> NSView {
|
|
|
1689
|
+ paywallPlanViews.removeAll()
|
|
|
1690
|
+ premiumPlanByView.removeAll()
|
|
|
1691
|
+ paywallOfferLabel = nil
|
|
|
1692
|
+ paywallContinueLabel = nil
|
|
|
1693
|
+ paywallContinueButton = nil
|
|
|
1694
|
+ paywallContinueEnabled = true
|
|
|
1695
|
+
|
|
|
1696
|
+ let panel = NSView()
|
|
|
1697
|
+ panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1698
|
+ panel.wantsLayer = true
|
|
|
1699
|
+ panel.layer?.backgroundColor = appBackground.cgColor
|
|
|
1700
|
+
|
|
|
1701
|
+ let contentStack = NSStackView()
|
|
|
1702
|
+ contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1703
|
+ contentStack.orientation = .vertical
|
|
|
1704
|
+ contentStack.spacing = 12
|
|
|
1705
|
+ contentStack.alignment = .leading
|
|
|
1706
|
+ panel.addSubview(contentStack)
|
|
|
1707
|
+
|
|
|
1708
|
+ let topRow = NSStackView()
|
|
|
1709
|
+ topRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1710
|
+ topRow.orientation = .horizontal
|
|
|
1711
|
+ topRow.alignment = .centerY
|
|
|
1712
|
+ topRow.distribution = .fill
|
|
|
1713
|
+ topRow.spacing = 10
|
|
|
1714
|
+ topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: primaryText))
|
|
|
1715
|
+ let topSpacer = NSView()
|
|
|
1716
|
+ topSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1717
|
+ topRow.addArrangedSubview(topSpacer)
|
|
|
1718
|
+
|
|
|
1719
|
+ let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
|
|
|
1720
|
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1721
|
+ closeButton.isBordered = false
|
|
|
1722
|
+ closeButton.bezelStyle = .regularSquare
|
|
|
1723
|
+ closeButton.wantsLayer = true
|
|
|
1724
|
+ closeButton.layer?.cornerRadius = 14
|
|
|
1725
|
+ closeButton.normalColor = palette.inputBackground
|
|
|
1726
|
+ closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
|
|
|
1727
|
+ closeButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
1728
|
+ closeButton.layer?.borderWidth = 1
|
|
|
1729
|
+ closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
|
|
|
1730
|
+ closeButton.contentTintColor = secondaryText
|
|
|
1731
|
+ closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
1732
|
+ closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
1733
|
+ topRow.addArrangedSubview(closeButton)
|
|
|
1734
|
+ topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
|
|
|
1735
|
+ contentStack.addArrangedSubview(topRow)
|
|
|
1736
|
+
|
|
|
1737
|
+ contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: secondaryText))
|
|
|
1738
|
+ let benefits = paywallBenefitsSection()
|
|
|
1739
|
+ contentStack.addArrangedSubview(benefits)
|
|
|
1740
|
+ contentStack.setCustomSpacing(18, after: benefits)
|
|
|
1741
|
+
|
|
|
1742
|
+ let weeklyCard = paywallPlanCard(
|
|
|
1743
|
+ title: "Weekly",
|
|
|
1744
|
+ price: "PKR 1,100.00",
|
|
|
1745
|
+ badge: "Basic Deal",
|
|
|
1746
|
+ badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
1747
|
+ subtitle: nil,
|
|
|
1748
|
+ plan: .weekly,
|
|
|
1749
|
+ strikePrice: nil
|
|
|
1750
|
+ )
|
|
|
1751
|
+ contentStack.addArrangedSubview(weeklyCard)
|
|
|
1752
|
+
|
|
|
1753
|
+ let monthlyCard = paywallPlanCard(
|
|
|
1754
|
+ title: "Monthly",
|
|
|
1755
|
+ price: "PKR 2,500.00",
|
|
|
1756
|
+ badge: "Free Trial",
|
|
|
1757
|
+ badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
|
1758
|
+ subtitle: "625.00/week",
|
|
|
1759
|
+ plan: .monthly,
|
|
|
1760
|
+ strikePrice: nil
|
|
|
1761
|
+ )
|
|
|
1762
|
+ contentStack.addArrangedSubview(monthlyCard)
|
|
|
1763
|
+
|
|
|
1764
|
+ let yearlyCard = paywallPlanCard(
|
|
|
1765
|
+ title: "Yearly",
|
|
|
1766
|
+ price: "PKR 9,900.00",
|
|
|
1767
|
+ badge: "Best Deal",
|
|
|
1768
|
+ badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
1769
|
+ subtitle: "190.38/week",
|
|
|
1770
|
+ plan: .yearly,
|
|
|
1771
|
+ strikePrice: nil
|
|
|
1772
|
+ )
|
|
|
1773
|
+ contentStack.addArrangedSubview(yearlyCard)
|
|
|
1774
|
+
|
|
|
1775
|
+ let lifetimeCard = paywallPlanCard(
|
|
|
1776
|
+ title: "Lifetime",
|
|
|
1777
|
+ price: "PKR 14,900.00",
|
|
|
1778
|
+ badge: "Save 50%",
|
|
|
1779
|
+ badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
1780
|
+ subtitle: nil,
|
|
|
1781
|
+ plan: .lifetime,
|
|
|
1782
|
+ strikePrice: "PKR 29,800.00"
|
|
|
1783
|
+ )
|
|
|
1784
|
+ contentStack.addArrangedSubview(lifetimeCard)
|
|
|
1785
|
+ updatePaywallPlanSelection()
|
|
|
1786
|
+ contentStack.setCustomSpacing(20, after: lifetimeCard)
|
|
|
1787
|
+
|
|
|
1788
|
+ let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
|
|
|
1789
|
+ offer.alignment = .center
|
|
|
1790
|
+ paywallOfferLabel = offer
|
|
|
1791
|
+ let offerWrap = NSView()
|
|
|
1792
|
+ offerWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1793
|
+ offerWrap.addSubview(offer)
|
|
|
1794
|
+ NSLayoutConstraint.activate([
|
|
|
1795
|
+ offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
|
|
|
1796
|
+ offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
|
|
|
1797
|
+ offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
|
|
|
1798
|
+ offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
|
|
|
1799
|
+ ])
|
|
|
1800
|
+ contentStack.addArrangedSubview(offerWrap)
|
|
|
1801
|
+ contentStack.setCustomSpacing(18, after: offerWrap)
|
|
|
1802
|
+
|
|
|
1803
|
+ let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
|
|
|
1804
|
+ continueButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1805
|
+ continueButton.isBordered = false
|
|
|
1806
|
+ continueButton.bezelStyle = .regularSquare
|
|
|
1807
|
+ continueButton.wantsLayer = true
|
|
|
1808
|
+ continueButton.layer?.cornerRadius = 14
|
|
|
1809
|
+ continueButton.normalColor = accentBlue
|
|
|
1810
|
+ continueButton.hoverColor = accentBlue.withAlphaComponent(0.92)
|
|
|
1811
|
+ continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
1812
|
+ continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
|
|
|
1813
|
+ styleSurface(continueButton, borderColor: accentBlue.withAlphaComponent(0.85), borderWidth: 1, shadow: true)
|
|
|
1814
|
+ let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
|
|
|
1815
|
+ continueButton.addSubview(continueLabel)
|
|
|
1816
|
+ NSLayoutConstraint.activate([
|
|
|
1817
|
+ continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
|
|
|
1818
|
+ continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
|
|
|
1819
|
+ ])
|
|
|
1820
|
+ paywallContinueButton = continueButton
|
|
|
1821
|
+ paywallContinueLabel = continueLabel
|
|
|
1822
|
+ contentStack.addArrangedSubview(continueButton)
|
|
|
1823
|
+ contentStack.setCustomSpacing(16, after: continueButton)
|
|
|
1824
|
+
|
|
|
1825
|
+ let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
|
|
|
1826
|
+ secure.alignment = .center
|
|
|
1827
|
+ let secureWrap = NSView()
|
|
|
1828
|
+ secureWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1829
|
+ secureWrap.addSubview(secure)
|
|
|
1830
|
+ NSLayoutConstraint.activate([
|
|
|
1831
|
+ secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
|
|
|
1832
|
+ secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
|
|
|
1833
|
+ secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
|
|
|
1834
|
+ secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
|
|
|
1835
|
+ ])
|
|
|
1836
|
+ contentStack.addArrangedSubview(secureWrap)
|
|
|
1837
|
+
|
|
|
1838
|
+ NSLayoutConstraint.activate([
|
|
|
1839
|
+ contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
|
|
|
1840
|
+ contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
|
|
|
1841
|
+ contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
|
|
|
1842
|
+ contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
|
|
|
1843
|
+ ])
|
|
|
1844
|
+
|
|
|
1845
|
+ refreshPaywallStoreUI()
|
|
|
1846
|
+ return panel
|
|
|
1847
|
+ }
|
|
|
1848
|
+
|
|
|
1849
|
+ private func paywallBenefitsSection() -> NSView {
|
|
|
1850
|
+ let stack = NSStackView()
|
|
|
1851
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1852
|
+ stack.orientation = .vertical
|
|
|
1853
|
+ stack.spacing = 8
|
|
|
1854
|
+ stack.alignment = .leading
|
|
|
1855
|
+ stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
|
|
|
1856
|
+
|
|
|
1857
|
+ let rowOne = NSStackView()
|
|
|
1858
|
+ rowOne.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1859
|
+ rowOne.orientation = .horizontal
|
|
|
1860
|
+ rowOne.spacing = 10
|
|
|
1861
|
+ rowOne.distribution = .fillEqually
|
|
|
1862
|
+ rowOne.alignment = .centerY
|
|
|
1863
|
+ rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
|
|
|
1864
|
+ rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
|
|
|
1865
|
+
|
|
|
1866
|
+ let rowTwo = NSStackView()
|
|
|
1867
|
+ rowTwo.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1868
|
+ rowTwo.orientation = .horizontal
|
|
|
1869
|
+ rowTwo.spacing = 10
|
|
|
1870
|
+ rowTwo.distribution = .fillEqually
|
|
|
1871
|
+ rowTwo.alignment = .centerY
|
|
|
1872
|
+ rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
|
|
|
1873
|
+ rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
|
|
|
1874
|
+
|
|
|
1875
|
+ stack.addArrangedSubview(rowOne)
|
|
|
1876
|
+ stack.addArrangedSubview(rowTwo)
|
|
|
1877
|
+ return stack
|
|
|
1878
|
+ }
|
|
|
1879
|
+
|
|
|
1880
|
+ private func paywallBenefitItem(icon: String, text: String) -> NSView {
|
|
|
1881
|
+ let card = NSView()
|
|
|
1882
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1883
|
+ card.wantsLayer = true
|
|
|
1884
|
+ card.layer?.cornerRadius = 10
|
|
|
1885
|
+ card.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
1886
|
+ card.heightAnchor.constraint(equalToConstant: 36).isActive = true
|
|
|
1887
|
+ styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
1888
|
+
|
|
|
1889
|
+ let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
|
|
|
1890
|
+ iconWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1891
|
+ iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
|
|
1892
|
+ iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
|
|
1893
|
+ styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
1894
|
+
|
|
|
1895
|
+ let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: accentBlue)
|
|
|
1896
|
+ iconWrap.addSubview(iconLabel)
|
|
|
1897
|
+ NSLayoutConstraint.activate([
|
|
|
1898
|
+ iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
|
|
|
1899
|
+ iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
|
|
|
1900
|
+ ])
|
|
|
1901
|
+
|
|
|
1902
|
+ let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: primaryText)
|
|
|
1903
|
+
|
|
|
1904
|
+ card.addSubview(iconWrap)
|
|
|
1905
|
+ card.addSubview(title)
|
|
|
1906
|
+ NSLayoutConstraint.activate([
|
|
|
1907
|
+ iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
|
|
|
1908
|
+ iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
|
|
1909
|
+ title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
|
|
|
1910
|
+ title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
|
|
1911
|
+ title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
|
|
|
1912
|
+ ])
|
|
|
1913
|
+
|
|
|
1914
|
+ return card
|
|
|
1915
|
+ }
|
|
|
1916
|
+
|
|
|
1917
|
+ private func paywallPlanCard(
|
|
|
1918
|
+ title: String,
|
|
|
1919
|
+ price: String,
|
|
|
1920
|
+ badge: String,
|
|
|
1921
|
+ badgeColor: NSColor,
|
|
|
1922
|
+ subtitle: String?,
|
|
|
1923
|
+ plan: PremiumPlan,
|
|
|
1924
|
+ strikePrice: String?
|
|
|
1925
|
+ ) -> NSView {
|
|
|
1926
|
+ let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
|
|
|
1927
|
+ wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1928
|
+ wrapper.isBordered = false
|
|
|
1929
|
+ wrapper.bezelStyle = .regularSquare
|
|
|
1930
|
+ wrapper.wantsLayer = true
|
|
|
1931
|
+ wrapper.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
1932
|
+ wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
|
|
|
1933
|
+ wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
|
|
|
1934
|
+ wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
|
|
|
1935
|
+
|
|
|
1936
|
+ let card = NSView()
|
|
|
1937
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1938
|
+ card.wantsLayer = true
|
|
|
1939
|
+ card.layer?.cornerRadius = 16
|
|
|
1940
|
+ card.layer?.backgroundColor = palette.sectionCard.cgColor
|
|
|
1941
|
+ card.heightAnchor.constraint(equalToConstant: 82).isActive = true
|
|
|
1942
|
+ wrapper.addSubview(card)
|
|
|
1943
|
+ NSLayoutConstraint.activate([
|
|
|
1944
|
+ card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
|
|
|
1945
|
+ card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
|
|
|
1946
|
+ card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
|
|
|
1947
|
+ card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
|
|
|
1948
|
+ ])
|
|
|
1949
|
+ styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
1950
|
+
|
|
|
1951
|
+ let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
|
|
|
1952
|
+ let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
|
|
|
1953
|
+ badgeWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1954
|
+ badgeWrap.wantsLayer = true
|
|
|
1955
|
+ badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
|
|
|
1956
|
+ badgeWrap.layer?.borderWidth = 1
|
|
|
1957
|
+ badgeWrap.layer?.shadowColor = NSColor.black.cgColor
|
|
|
1958
|
+ badgeWrap.layer?.shadowOpacity = 0.20
|
|
|
1959
|
+ badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
|
|
|
1960
|
+ badgeWrap.layer?.shadowRadius = 3
|
|
|
1961
|
+ badgeWrap.addSubview(badgeLabel)
|
|
|
1962
|
+ NSLayoutConstraint.activate([
|
|
|
1963
|
+ badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
|
|
|
1964
|
+ badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
|
|
|
1965
|
+ badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
|
|
|
1966
|
+ badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
|
|
|
1967
|
+ ])
|
|
|
1968
|
+ wrapper.addSubview(badgeWrap)
|
|
|
1969
|
+
|
|
|
1970
|
+ let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: accentBlue)
|
|
|
1971
|
+ card.addSubview(titleLabel)
|
|
|
1972
|
+ let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: primaryText)
|
|
|
1973
|
+ card.addSubview(priceLabel)
|
|
|
1974
|
+
|
|
|
1975
|
+ NSLayoutConstraint.activate([
|
|
|
1976
|
+ badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
|
|
|
1977
|
+ badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
|
|
|
1978
|
+
|
|
|
1979
|
+ titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
|
|
|
1980
|
+ titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
|
|
|
1981
|
+
|
|
|
1982
|
+ priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
|
|
|
1983
|
+ priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
|
|
|
1984
|
+ ])
|
|
|
1985
|
+
|
|
|
1986
|
+ if let subtitle {
|
|
|
1987
|
+ let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: secondaryText)
|
|
|
1988
|
+ card.addSubview(sub)
|
|
|
1989
|
+ NSLayoutConstraint.activate([
|
|
|
1990
|
+ sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
|
|
|
1991
|
+ sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
|
|
|
1992
|
+ ])
|
|
|
1993
|
+ }
|
|
|
1994
|
+
|
|
|
1995
|
+ if let strikePrice {
|
|
|
1996
|
+ let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
|
|
|
1997
|
+ card.addSubview(strike)
|
|
|
1998
|
+ NSLayoutConstraint.activate([
|
|
|
1999
|
+ strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
|
|
|
2000
|
+ strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
|
|
|
2001
|
+ ])
|
|
|
2002
|
+ }
|
|
|
2003
|
+
|
|
|
2004
|
+ paywallPlanViews[plan] = card
|
|
|
2005
|
+ premiumPlanByView[ObjectIdentifier(card)] = plan
|
|
|
2006
|
+ wrapper.onHoverChanged = { [weak self, weak card] hovering in
|
|
|
2007
|
+ guard let self, let card else { return }
|
|
|
2008
|
+ self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
|
|
|
2009
|
+ }
|
|
|
2010
|
+ wrapper.onHoverChanged?(false)
|
|
|
2011
|
+ return wrapper
|
|
|
2012
|
+ }
|
|
|
2013
|
+
|
|
|
2014
|
+ @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
|
|
|
2015
|
+ guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
|
|
|
2016
|
+ selectedPremiumPlan = plan
|
|
|
2017
|
+ updatePaywallPlanSelection()
|
|
|
2018
|
+ }
|
|
|
2019
|
+
|
|
|
2020
|
+ private func updatePaywallPlanSelection() {
|
|
|
2021
|
+ for (plan, view) in paywallPlanViews {
|
|
|
2022
|
+ applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
|
|
|
2023
|
+ }
|
|
|
2024
|
+ paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
|
|
|
2025
|
+ }
|
|
|
2026
|
+
|
|
|
2027
|
+ private func paywallOfferText(for plan: PremiumPlan) -> String {
|
|
|
2028
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
2029
|
+ return "Premium is active on this Apple ID."
|
|
|
2030
|
+ }
|
|
|
2031
|
+ if let product = storeKitCoordinator.productsByID[plan.rawValue] {
|
|
|
2032
|
+ return "\(product.displayPrice) purchase"
|
|
|
2033
|
+ }
|
|
|
2034
|
+ switch plan {
|
|
|
2035
|
+ case .weekly: return "PKR 1,100.00/week"
|
|
|
2036
|
+ case .monthly: return "PKR 2,500.00/month"
|
|
|
2037
|
+ case .yearly: return "PKR 9,900.00/year"
|
|
|
2038
|
+ case .lifetime: return "PKR 14,900.00 one-time purchase"
|
|
|
2039
|
+ }
|
|
|
2040
|
+ }
|
|
|
2041
|
+
|
|
|
2042
|
+ private func refreshPaywallStoreUI() {
|
|
|
2043
|
+ updatePaywallPlanSelection()
|
|
|
2044
|
+ updatePaywallContinueState(isLoading: false)
|
|
|
2045
|
+ }
|
|
|
2046
|
+
|
|
|
2047
|
+ @objc private func paywallContinueClicked(_ sender: Any?) {
|
|
|
2048
|
+ startSelectedPlanPurchase()
|
|
|
2049
|
+ }
|
|
|
2050
|
+
|
|
|
2051
|
+ private func startSelectedPlanPurchase() {
|
|
|
2052
|
+ guard paywallContinueEnabled else { return }
|
|
|
2053
|
+ paywallPurchaseTask?.cancel()
|
|
|
2054
|
+ updatePaywallContinueState(isLoading: true)
|
|
|
2055
|
+ let selectedPlan = selectedPremiumPlan
|
|
|
2056
|
+ paywallPurchaseTask = Task { [weak self] in
|
|
|
2057
|
+ guard let self else { return }
|
|
|
2058
|
+ let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
|
|
|
2059
|
+ await MainActor.run {
|
|
|
2060
|
+ self.updatePaywallContinueState(isLoading: false)
|
|
|
2061
|
+ self.refreshPaywallStoreUI()
|
|
|
2062
|
+ self.updatePremiumButtons()
|
|
|
2063
|
+ switch result {
|
|
|
2064
|
+ case .success:
|
|
|
2065
|
+ self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
|
|
|
2066
|
+ self.paywallWindow?.performClose(nil)
|
|
|
2067
|
+ self.paywallWindow = nil
|
|
|
2068
|
+ case .pending:
|
|
|
2069
|
+ self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
|
|
|
2070
|
+ case .cancelled:
|
|
|
2071
|
+ break
|
|
|
2072
|
+ case .failed(let message):
|
|
|
2073
|
+ self.showSimpleAlert(title: "Purchase Failed", message: message)
|
|
|
2074
|
+ }
|
|
|
2075
|
+ }
|
|
|
2076
|
+ }
|
|
|
2077
|
+ }
|
|
|
2078
|
+
|
|
|
2079
|
+ private func updatePaywallContinueState(isLoading: Bool) {
|
|
|
2080
|
+ if isLoading {
|
|
|
2081
|
+ paywallContinueEnabled = false
|
|
|
2082
|
+ paywallContinueLabel?.stringValue = "Processing..."
|
|
|
2083
|
+ paywallContinueButton?.alphaValue = 0.75
|
|
|
2084
|
+ return
|
|
|
2085
|
+ }
|
|
|
2086
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
2087
|
+ paywallContinueEnabled = false
|
|
|
2088
|
+ paywallContinueLabel?.stringValue = "Premium Active"
|
|
|
2089
|
+ paywallContinueButton?.alphaValue = 0.75
|
|
|
2090
|
+ } else {
|
|
|
2091
|
+ paywallContinueEnabled = true
|
|
|
2092
|
+ paywallContinueLabel?.stringValue = "Continue"
|
|
|
2093
|
+ paywallContinueButton?.alphaValue = 1.0
|
|
|
2094
|
+ }
|
|
|
2095
|
+ }
|
|
|
2096
|
+
|
|
|
2097
|
+ private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
|
|
|
2098
|
+ let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
|
|
|
2099
|
+ let idleBorder = palette.inputBorder
|
|
|
2100
|
+ let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
|
|
|
2101
|
+ let hoverIdleBackground =
|
|
|
2102
|
+ palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
|
|
|
2103
|
+ let selectedBackground = palette.isDarkMode
|
|
|
2104
|
+ ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
|
|
|
2105
|
+ : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
|
|
|
2106
|
+ card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
|
|
|
2107
|
+ card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
|
|
|
2108
|
+ card.layer?.borderWidth = isSelected ? 2 : 1
|
|
|
2109
|
+ card.layer?.shadowColor = NSColor.black.cgColor
|
|
|
2110
|
+ card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
|
|
|
2111
|
+ card.layer?.shadowOffset = CGSize(width: 0, height: -1)
|
|
|
2112
|
+ card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
|
|
|
2113
|
+ }
|
|
|
2114
|
+
|
|
1551
|
2115
|
// MARK: - Home UI
|
|
1552
|
2116
|
|
|
1553
|
2117
|
private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
|
|
|
@@ -1701,7 +2265,9 @@ class ViewController: NSViewController {
|
|
1701
|
2265
|
rightTopBarCluster.orientation = .horizontal
|
|
1702
|
2266
|
rightTopBarCluster.spacing = 10
|
|
1703
|
2267
|
rightTopBarCluster.alignment = .centerY
|
|
1704
|
|
- let upgradeToProButton = makeUpgradeToProButton(action: #selector(topBarPlaceholderTapped))
|
|
|
2268
|
+ let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
|
|
|
2269
|
+ topBarPremiumButton = upgradeToProButton
|
|
|
2270
|
+ updateTopBarPremiumButton()
|
|
1705
|
2271
|
|
|
1706
|
2272
|
let profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
|
|
1707
|
2273
|
profileChip.isBordered = false
|
|
|
@@ -2508,6 +3074,13 @@ class ViewController: NSViewController {
|
|
2508
|
3074
|
}
|
|
2509
|
3075
|
}
|
|
2510
|
3076
|
|
|
|
3077
|
+private extension Array {
|
|
|
3078
|
+ subscript(safe index: Int) -> Element? {
|
|
|
3079
|
+ guard index >= 0, index < count else { return nil }
|
|
|
3080
|
+ return self[index]
|
|
|
3081
|
+ }
|
|
|
3082
|
+}
|
|
|
3083
|
+
|
|
2511
|
3084
|
private final class SearchPillTextField: NSTextField {
|
|
2512
|
3085
|
var onFocusChange: ((Bool) -> Void)?
|
|
2513
|
3086
|
private(set) var isSearchFocused = false
|