|
@@ -96,6 +96,7 @@ class ViewController: NSViewController {
|
|
|
private final class HoverButton: NSButton {
|
|
private final class HoverButton: NSButton {
|
|
|
var normalColor: NSColor = .clear { didSet { applyBackground() } }
|
|
var normalColor: NSColor = .clear { didSet { applyBackground() } }
|
|
|
var hoverColor: NSColor = .clear
|
|
var hoverColor: NSColor = .clear
|
|
|
|
|
+ var onHoverChanged: ((Bool) -> Void)?
|
|
|
private var tracking: NSTrackingArea?
|
|
private var tracking: NSTrackingArea?
|
|
|
private var hovering = false { didSet { applyBackground() } }
|
|
private var hovering = false { didSet { applyBackground() } }
|
|
|
|
|
|
|
@@ -109,10 +110,12 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
override func mouseEntered(with event: NSEvent) {
|
|
override func mouseEntered(with event: NSEvent) {
|
|
|
hovering = true
|
|
hovering = true
|
|
|
|
|
+ onHoverChanged?(true)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
override func mouseExited(with event: NSEvent) {
|
|
|
hovering = false
|
|
hovering = false
|
|
|
|
|
+ onHoverChanged?(false)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func applyBackground() {
|
|
private func applyBackground() {
|
|
@@ -183,6 +186,17 @@ class ViewController: NSViewController {
|
|
|
private weak var settingsUpgradeButton: NSButton?
|
|
private weak var settingsUpgradeButton: NSButton?
|
|
|
private weak var settingsRestoreButton: NSButton?
|
|
private weak var settingsRestoreButton: NSButton?
|
|
|
private weak var settingsGoogleActionButton: NSButton?
|
|
private weak var settingsGoogleActionButton: NSButton?
|
|
|
|
|
+ private weak var topBarPremiumButton: NSButton?
|
|
|
|
|
+ private var paywallWindow: NSWindow?
|
|
|
|
|
+ private let paywallContentWidth: CGFloat = 520
|
|
|
|
|
+ private var selectedPremiumPlan: PremiumPlan = .monthly
|
|
|
|
|
+ private var paywallPlanViews: [PremiumPlan: NSView] = [:]
|
|
|
|
|
+ private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
|
|
|
|
|
+ private weak var paywallOfferLabel: NSTextField?
|
|
|
|
|
+ private weak var paywallContinueLabel: NSTextField?
|
|
|
|
|
+ private weak var paywallContinueButton: NSView?
|
|
|
|
|
+ private var paywallPurchaseTask: Task<Void, Never>?
|
|
|
|
|
+ private var paywallContinueEnabled = true
|
|
|
private var allScheduledMeetings: [ScheduledMeeting] = []
|
|
private var allScheduledMeetings: [ScheduledMeeting] = []
|
|
|
private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
|
|
private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
|
|
|
private var selectedHomeSidebarItem: String = "Home"
|
|
private var selectedHomeSidebarItem: String = "Home"
|
|
@@ -314,20 +328,41 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func refreshEntitlements() async {
|
|
private func refreshEntitlements() async {
|
|
|
|
|
+ let previousHasPremiumAccess = hasPremiumAccess
|
|
|
|
|
+ let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
|
|
|
var active = Set<String>()
|
|
var active = Set<String>()
|
|
|
|
|
+
|
|
|
for await entitlement in Transaction.currentEntitlements {
|
|
for await entitlement in Transaction.currentEntitlements {
|
|
|
guard case .verified(let transaction) = entitlement else { continue }
|
|
guard case .verified(let transaction) = entitlement else { continue }
|
|
|
- if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID),
|
|
|
|
|
- transaction.revocationDate == nil {
|
|
|
|
|
|
|
+ guard allIDs.contains(transaction.productID) else { continue }
|
|
|
|
|
+ if Self.isTransactionActive(transaction) {
|
|
|
active.insert(transaction.productID)
|
|
active.insert(transaction.productID)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- let changed = active != activeProductIDs
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // StoreKit testing can briefly report empty current entitlements even though a latest
|
|
|
|
|
+ // verified transaction exists for a non-consumable. Merge in latest transactions.
|
|
|
|
|
+ for productID in allIDs {
|
|
|
|
|
+ guard let latest = await Transaction.latest(for: productID),
|
|
|
|
|
+ case .verified(let transaction) = latest,
|
|
|
|
|
+ Self.isTransactionActive(transaction) else { continue }
|
|
|
|
|
+ active.insert(productID)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
activeProductIDs = active
|
|
activeProductIDs = active
|
|
|
- if changed {
|
|
|
|
|
- onEntitlementsChanged?(hasPremiumAccess)
|
|
|
|
|
|
|
+ let newHasPremiumAccess = hasPremiumAccess
|
|
|
|
|
+ if newHasPremiumAccess != previousHasPremiumAccess {
|
|
|
|
|
+ onEntitlementsChanged?(newHasPremiumAccess)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ private static func isTransactionActive(_ transaction: Transaction) -> Bool {
|
|
|
|
|
+ if transaction.revocationDate != nil { return false }
|
|
|
|
|
+ if let expirationDate = transaction.expirationDate {
|
|
|
|
|
+ return expirationDate > Date()
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private let storeKitCoordinator = StoreKitCoordinator()
|
|
private let storeKitCoordinator = StoreKitCoordinator()
|
|
@@ -346,7 +381,9 @@ class ViewController: NSViewController {
|
|
|
super.viewDidAppear()
|
|
super.viewDidAppear()
|
|
|
if let window = view.window {
|
|
if let window = view.window {
|
|
|
window.setContentSize(NSSize(width: 1020, height: 690))
|
|
window.setContentSize(NSSize(width: 1020, height: 690))
|
|
|
- window.backgroundColor = chromeUnifiedBackground
|
|
|
|
|
|
|
+ // Match the outer window background to the app background so rounded shell corners
|
|
|
|
|
+ // don't reveal a lighter window color in Dark Mode.
|
|
|
|
|
+ window.backgroundColor = appBackground
|
|
|
// Use full-size content view so custom top chrome sits in the titlebar region.
|
|
// Use full-size content view so custom top chrome sits in the titlebar region.
|
|
|
window.titleVisibility = .hidden
|
|
window.titleVisibility = .hidden
|
|
|
window.titlebarAppearsTransparent = true
|
|
window.titlebarAppearsTransparent = true
|
|
@@ -528,6 +565,20 @@ class ViewController: NSViewController {
|
|
|
// Reserved for future titlebar control actions.
|
|
// Reserved for future titlebar control actions.
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @objc private func upgradeToProTapped() {
|
|
|
|
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
|
|
+ openManageSubscriptions()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showPaywall()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func openManageSubscriptions() {
|
|
|
|
|
+ if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
|
|
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@objc private func refreshMeetingsTapped() {
|
|
@objc private func refreshMeetingsTapped() {
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
self.animateMeetingsRefresh()
|
|
self.animateMeetingsRefresh()
|
|
@@ -1487,6 +1538,44 @@ class ViewController: NSViewController {
|
|
|
settingsUpgradeButton?.isEnabled = isPremium == false
|
|
settingsUpgradeButton?.isEnabled = isPremium == false
|
|
|
settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
|
|
settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
|
|
|
settingsRestoreButton?.isEnabled = true
|
|
settingsRestoreButton?.isEnabled = true
|
|
|
|
|
+
|
|
|
|
|
+ updateTopBarPremiumButton()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func updateTopBarPremiumButton() {
|
|
|
|
|
+ guard let button = topBarPremiumButton else { return }
|
|
|
|
|
+ let isPremium = storeKitCoordinator.hasPremiumAccess
|
|
|
|
|
+ if isPremium {
|
|
|
|
|
+ let title = "Manage Subscription"
|
|
|
|
|
+ let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
|
|
+ button.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
|
|
|
+ .foregroundColor: NSColor.white,
|
|
|
|
|
+ .font: font
|
|
|
|
|
+ ])
|
|
|
|
|
+ let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
|
|
|
|
|
+ if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
|
|
|
|
|
+ let img = base.withSymbolConfiguration(symbolConfig) {
|
|
|
|
|
+ img.isTemplate = true
|
|
|
|
|
+ button.image = img
|
|
|
|
|
+ button.imagePosition = .imageLeading
|
|
|
|
|
+ button.imageHugsTitle = true
|
|
|
|
|
+ }
|
|
|
|
|
+ button.contentTintColor = NSColor.white
|
|
|
|
|
+ button.toolTip = title
|
|
|
|
|
+ button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let title = "Upgrade to Pro"
|
|
|
|
|
+ let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
|
|
+ button.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
|
|
|
+ .foregroundColor: NSColor.white,
|
|
|
|
|
+ .font: font
|
|
|
|
|
+ ])
|
|
|
|
|
+ button.image = nil
|
|
|
|
|
+ button.toolTip = title
|
|
|
|
|
+ button.layer?.backgroundColor = accentBlue.cgColor
|
|
|
|
|
+ button.contentTintColor = .white
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
|
|
@objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
|
|
@@ -1548,6 +1637,481 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // MARK: - Paywall (ported from meetings_app)
|
|
|
|
|
+
|
|
|
|
|
+ private func showPaywall() {
|
|
|
|
|
+ if let existing = paywallWindow {
|
|
|
|
|
+ refreshPaywallStoreUI()
|
|
|
|
|
+ existing.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let content = makePaywallContent()
|
|
|
|
|
+ let controller = NSViewController()
|
|
|
|
|
+ controller.view = content
|
|
|
|
|
+
|
|
|
|
|
+ let panel = NSPanel(
|
|
|
|
|
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
|
|
|
|
|
+ styleMask: [.titled, .closable, .fullSizeContentView],
|
|
|
|
|
+ backing: .buffered,
|
|
|
|
|
+ defer: false
|
|
|
|
|
+ )
|
|
|
|
|
+ panel.title = "Get Premium"
|
|
|
|
|
+ panel.titleVisibility = .hidden
|
|
|
|
|
+ panel.titlebarAppearsTransparent = true
|
|
|
|
|
+ panel.hidesOnDeactivate = true
|
|
|
|
|
+ panel.isReleasedWhenClosed = false
|
|
|
|
|
+ panel.standardWindowButton(.closeButton)?.isHidden = true
|
|
|
|
|
+ panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
|
|
|
+ panel.standardWindowButton(.zoomButton)?.isHidden = true
|
|
|
|
|
+ panel.center()
|
|
|
|
|
+ panel.contentViewController = controller
|
|
|
|
|
+ panel.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ paywallWindow = panel
|
|
|
|
|
+
|
|
|
|
|
+ Task { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ await self.storeKitCoordinator.refreshProducts()
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ self.refreshPaywallStoreUI()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func closePaywallClicked(_ sender: Any?) {
|
|
|
|
|
+ paywallWindow?.performClose(nil)
|
|
|
|
|
+ paywallWindow = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makePaywallContent() -> NSView {
|
|
|
|
|
+ paywallPlanViews.removeAll()
|
|
|
|
|
+ premiumPlanByView.removeAll()
|
|
|
|
|
+ paywallOfferLabel = nil
|
|
|
|
|
+ paywallContinueLabel = nil
|
|
|
|
|
+ paywallContinueButton = nil
|
|
|
|
|
+ paywallContinueEnabled = true
|
|
|
|
|
+
|
|
|
|
|
+ let panel = NSView()
|
|
|
|
|
+ panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ panel.wantsLayer = true
|
|
|
|
|
+ panel.layer?.backgroundColor = appBackground.cgColor
|
|
|
|
|
+
|
|
|
|
|
+ let contentStack = NSStackView()
|
|
|
|
|
+ contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ contentStack.orientation = .vertical
|
|
|
|
|
+ contentStack.spacing = 12
|
|
|
|
|
+ contentStack.alignment = .leading
|
|
|
|
|
+ panel.addSubview(contentStack)
|
|
|
|
|
+
|
|
|
|
|
+ let topRow = NSStackView()
|
|
|
|
|
+ topRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ topRow.orientation = .horizontal
|
|
|
|
|
+ topRow.alignment = .centerY
|
|
|
|
|
+ topRow.distribution = .fill
|
|
|
|
|
+ topRow.spacing = 10
|
|
|
|
|
+ topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: primaryText))
|
|
|
|
|
+ let topSpacer = NSView()
|
|
|
|
|
+ topSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ topRow.addArrangedSubview(topSpacer)
|
|
|
|
|
+
|
|
|
|
|
+ let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
|
|
|
|
|
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ closeButton.isBordered = false
|
|
|
|
|
+ closeButton.bezelStyle = .regularSquare
|
|
|
|
|
+ closeButton.wantsLayer = true
|
|
|
|
|
+ closeButton.layer?.cornerRadius = 14
|
|
|
|
|
+ closeButton.normalColor = palette.inputBackground
|
|
|
|
|
+ closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
|
|
|
|
|
+ closeButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ closeButton.layer?.borderWidth = 1
|
|
|
|
|
+ closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
|
|
|
|
|
+ closeButton.contentTintColor = secondaryText
|
|
|
|
|
+ closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
|
|
+ closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
|
|
+ topRow.addArrangedSubview(closeButton)
|
|
|
|
|
+ topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
|
|
|
|
|
+ contentStack.addArrangedSubview(topRow)
|
|
|
|
|
+
|
|
|
|
|
+ contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: secondaryText))
|
|
|
|
|
+ let benefits = paywallBenefitsSection()
|
|
|
|
|
+ contentStack.addArrangedSubview(benefits)
|
|
|
|
|
+ contentStack.setCustomSpacing(18, after: benefits)
|
|
|
|
|
+
|
|
|
|
|
+ let weeklyCard = paywallPlanCard(
|
|
|
|
|
+ title: "Weekly",
|
|
|
|
|
+ price: "PKR 1,100.00",
|
|
|
|
|
+ badge: "Basic Deal",
|
|
|
|
|
+ badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
|
|
+ subtitle: nil,
|
|
|
|
|
+ plan: .weekly,
|
|
|
|
|
+ strikePrice: nil
|
|
|
|
|
+ )
|
|
|
|
|
+ contentStack.addArrangedSubview(weeklyCard)
|
|
|
|
|
+
|
|
|
|
|
+ let monthlyCard = paywallPlanCard(
|
|
|
|
|
+ title: "Monthly",
|
|
|
|
|
+ price: "PKR 2,500.00",
|
|
|
|
|
+ badge: "Free Trial",
|
|
|
|
|
+ badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
|
|
|
+ subtitle: "625.00/week",
|
|
|
|
|
+ plan: .monthly,
|
|
|
|
|
+ strikePrice: nil
|
|
|
|
|
+ )
|
|
|
|
|
+ contentStack.addArrangedSubview(monthlyCard)
|
|
|
|
|
+
|
|
|
|
|
+ let yearlyCard = paywallPlanCard(
|
|
|
|
|
+ title: "Yearly",
|
|
|
|
|
+ price: "PKR 9,900.00",
|
|
|
|
|
+ badge: "Best Deal",
|
|
|
|
|
+ badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
|
|
+ subtitle: "190.38/week",
|
|
|
|
|
+ plan: .yearly,
|
|
|
|
|
+ strikePrice: nil
|
|
|
|
|
+ )
|
|
|
|
|
+ contentStack.addArrangedSubview(yearlyCard)
|
|
|
|
|
+
|
|
|
|
|
+ let lifetimeCard = paywallPlanCard(
|
|
|
|
|
+ title: "Lifetime",
|
|
|
|
|
+ price: "PKR 14,900.00",
|
|
|
|
|
+ badge: "Save 50%",
|
|
|
|
|
+ badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
|
|
+ subtitle: nil,
|
|
|
|
|
+ plan: .lifetime,
|
|
|
|
|
+ strikePrice: "PKR 29,800.00"
|
|
|
|
|
+ )
|
|
|
|
|
+ contentStack.addArrangedSubview(lifetimeCard)
|
|
|
|
|
+ updatePaywallPlanSelection()
|
|
|
|
|
+ contentStack.setCustomSpacing(20, after: lifetimeCard)
|
|
|
|
|
+
|
|
|
|
|
+ let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
|
|
|
|
|
+ offer.alignment = .center
|
|
|
|
|
+ paywallOfferLabel = offer
|
|
|
|
|
+ let offerWrap = NSView()
|
|
|
|
|
+ offerWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ offerWrap.addSubview(offer)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
|
|
|
|
|
+ offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
|
|
|
|
|
+ offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
|
|
|
|
|
+ offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
|
|
|
|
|
+ ])
|
|
|
|
|
+ contentStack.addArrangedSubview(offerWrap)
|
|
|
|
|
+ contentStack.setCustomSpacing(18, after: offerWrap)
|
|
|
|
|
+
|
|
|
|
|
+ let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
|
|
|
|
|
+ continueButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ continueButton.isBordered = false
|
|
|
|
|
+ continueButton.bezelStyle = .regularSquare
|
|
|
|
|
+ continueButton.wantsLayer = true
|
|
|
|
|
+ continueButton.layer?.cornerRadius = 14
|
|
|
|
|
+ continueButton.normalColor = accentBlue
|
|
|
|
|
+ continueButton.hoverColor = accentBlue.withAlphaComponent(0.92)
|
|
|
|
|
+ continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
|
|
+ continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
|
|
|
|
|
+ styleSurface(continueButton, borderColor: accentBlue.withAlphaComponent(0.85), borderWidth: 1, shadow: true)
|
|
|
|
|
+ let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
|
|
|
|
|
+ continueButton.addSubview(continueLabel)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
|
|
|
|
|
+ continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+ paywallContinueButton = continueButton
|
|
|
|
|
+ paywallContinueLabel = continueLabel
|
|
|
|
|
+ contentStack.addArrangedSubview(continueButton)
|
|
|
|
|
+ contentStack.setCustomSpacing(16, after: continueButton)
|
|
|
|
|
+
|
|
|
|
|
+ let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
|
|
|
|
|
+ secure.alignment = .center
|
|
|
|
|
+ let secureWrap = NSView()
|
|
|
|
|
+ secureWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ secureWrap.addSubview(secure)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
|
|
|
|
|
+ secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
|
|
|
|
|
+ secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
|
|
|
|
|
+ secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
|
|
|
|
|
+ ])
|
|
|
|
|
+ contentStack.addArrangedSubview(secureWrap)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
|
|
|
|
|
+ contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
|
|
|
|
|
+ contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
|
|
|
|
|
+ contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ refreshPaywallStoreUI()
|
|
|
|
|
+ return panel
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func paywallBenefitsSection() -> NSView {
|
|
|
|
|
+ let stack = NSStackView()
|
|
|
|
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ stack.orientation = .vertical
|
|
|
|
|
+ stack.spacing = 8
|
|
|
|
|
+ stack.alignment = .leading
|
|
|
|
|
+ stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ let rowOne = NSStackView()
|
|
|
|
|
+ rowOne.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ rowOne.orientation = .horizontal
|
|
|
|
|
+ rowOne.spacing = 10
|
|
|
|
|
+ rowOne.distribution = .fillEqually
|
|
|
|
|
+ rowOne.alignment = .centerY
|
|
|
|
|
+ rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
|
|
|
|
|
+ rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
|
|
|
|
|
+
|
|
|
|
|
+ let rowTwo = NSStackView()
|
|
|
|
|
+ rowTwo.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ rowTwo.orientation = .horizontal
|
|
|
|
|
+ rowTwo.spacing = 10
|
|
|
|
|
+ rowTwo.distribution = .fillEqually
|
|
|
|
|
+ rowTwo.alignment = .centerY
|
|
|
|
|
+ rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
|
|
|
|
|
+ rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
|
|
|
|
|
+
|
|
|
|
|
+ stack.addArrangedSubview(rowOne)
|
|
|
|
|
+ stack.addArrangedSubview(rowTwo)
|
|
|
|
|
+ return stack
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func paywallBenefitItem(icon: String, text: String) -> NSView {
|
|
|
|
|
+ let card = NSView()
|
|
|
|
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ card.wantsLayer = true
|
|
|
|
|
+ card.layer?.cornerRadius = 10
|
|
|
|
|
+ card.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
|
|
+ card.heightAnchor.constraint(equalToConstant: 36).isActive = true
|
|
|
|
|
+ styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
+
|
|
|
|
|
+ let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
|
|
|
|
|
+ iconWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
|
|
|
|
+ iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
|
|
|
|
+ styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
+
|
|
|
|
|
+ let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: accentBlue)
|
|
|
|
|
+ iconWrap.addSubview(iconLabel)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
|
|
|
|
|
+ iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: primaryText)
|
|
|
|
|
+
|
|
|
|
|
+ card.addSubview(iconWrap)
|
|
|
|
|
+ card.addSubview(title)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
|
|
|
|
|
+ iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
|
|
|
|
+ title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
|
|
|
|
|
+ title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
|
|
|
|
+ title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ return card
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func paywallPlanCard(
|
|
|
|
|
+ title: String,
|
|
|
|
|
+ price: String,
|
|
|
|
|
+ badge: String,
|
|
|
|
|
+ badgeColor: NSColor,
|
|
|
|
|
+ subtitle: String?,
|
|
|
|
|
+ plan: PremiumPlan,
|
|
|
|
|
+ strikePrice: String?
|
|
|
|
|
+ ) -> NSView {
|
|
|
|
|
+ let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
|
|
|
|
|
+ wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ wrapper.isBordered = false
|
|
|
|
|
+ wrapper.bezelStyle = .regularSquare
|
|
|
|
|
+ wrapper.wantsLayer = true
|
|
|
|
|
+ wrapper.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
|
+ wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
|
|
|
|
|
+ wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
|
|
|
|
|
+ wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
|
|
|
|
|
+
|
|
|
|
|
+ let card = NSView()
|
|
|
|
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ card.wantsLayer = true
|
|
|
|
|
+ card.layer?.cornerRadius = 16
|
|
|
|
|
+ card.layer?.backgroundColor = palette.sectionCard.cgColor
|
|
|
|
|
+ card.heightAnchor.constraint(equalToConstant: 82).isActive = true
|
|
|
|
|
+ wrapper.addSubview(card)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
|
|
|
|
|
+ card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
|
|
|
|
|
+ card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
|
|
|
|
|
+ card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+ styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
+
|
|
|
|
|
+ let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
|
|
|
|
|
+ let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
|
|
|
|
|
+ badgeWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ badgeWrap.wantsLayer = true
|
|
|
|
|
+ badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
|
|
|
|
|
+ badgeWrap.layer?.borderWidth = 1
|
|
|
|
|
+ badgeWrap.layer?.shadowColor = NSColor.black.cgColor
|
|
|
|
|
+ badgeWrap.layer?.shadowOpacity = 0.20
|
|
|
|
|
+ badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
|
|
|
|
|
+ badgeWrap.layer?.shadowRadius = 3
|
|
|
|
|
+ badgeWrap.addSubview(badgeLabel)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
|
|
|
|
|
+ badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
|
|
|
|
|
+ badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
|
|
|
|
|
+ badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
|
|
|
|
|
+ ])
|
|
|
|
|
+ wrapper.addSubview(badgeWrap)
|
|
|
|
|
+
|
|
|
|
|
+ let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: accentBlue)
|
|
|
|
|
+ card.addSubview(titleLabel)
|
|
|
|
|
+ let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: primaryText)
|
|
|
|
|
+ card.addSubview(priceLabel)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
|
|
|
|
|
+ badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
|
|
|
|
|
+ titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
|
|
|
|
|
+
|
|
|
|
|
+ priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
|
|
|
|
|
+ priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ if let subtitle {
|
|
|
|
|
+ let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: secondaryText)
|
|
|
|
|
+ card.addSubview(sub)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
|
|
|
|
|
+ sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let strikePrice {
|
|
|
|
|
+ let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
|
|
|
|
|
+ card.addSubview(strike)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
|
|
|
|
|
+ strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ paywallPlanViews[plan] = card
|
|
|
|
|
+ premiumPlanByView[ObjectIdentifier(card)] = plan
|
|
|
|
|
+ wrapper.onHoverChanged = { [weak self, weak card] hovering in
|
|
|
|
|
+ guard let self, let card else { return }
|
|
|
|
|
+ self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
|
|
|
|
|
+ }
|
|
|
|
|
+ wrapper.onHoverChanged?(false)
|
|
|
|
|
+ return wrapper
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
|
|
|
|
|
+ guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
|
|
|
|
|
+ selectedPremiumPlan = plan
|
|
|
|
|
+ updatePaywallPlanSelection()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func updatePaywallPlanSelection() {
|
|
|
|
|
+ for (plan, view) in paywallPlanViews {
|
|
|
|
|
+ applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
|
|
|
|
|
+ }
|
|
|
|
|
+ paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func paywallOfferText(for plan: PremiumPlan) -> String {
|
|
|
|
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
|
|
+ return "Premium is active on this Apple ID."
|
|
|
|
|
+ }
|
|
|
|
|
+ if let product = storeKitCoordinator.productsByID[plan.rawValue] {
|
|
|
|
|
+ return "\(product.displayPrice) purchase"
|
|
|
|
|
+ }
|
|
|
|
|
+ switch plan {
|
|
|
|
|
+ case .weekly: return "PKR 1,100.00/week"
|
|
|
|
|
+ case .monthly: return "PKR 2,500.00/month"
|
|
|
|
|
+ case .yearly: return "PKR 9,900.00/year"
|
|
|
|
|
+ case .lifetime: return "PKR 14,900.00 one-time purchase"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func refreshPaywallStoreUI() {
|
|
|
|
|
+ updatePaywallPlanSelection()
|
|
|
|
|
+ updatePaywallContinueState(isLoading: false)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func paywallContinueClicked(_ sender: Any?) {
|
|
|
|
|
+ startSelectedPlanPurchase()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func startSelectedPlanPurchase() {
|
|
|
|
|
+ guard paywallContinueEnabled else { return }
|
|
|
|
|
+ paywallPurchaseTask?.cancel()
|
|
|
|
|
+ updatePaywallContinueState(isLoading: true)
|
|
|
|
|
+ let selectedPlan = selectedPremiumPlan
|
|
|
|
|
+ paywallPurchaseTask = Task { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ self.updatePaywallContinueState(isLoading: false)
|
|
|
|
|
+ self.refreshPaywallStoreUI()
|
|
|
|
|
+ self.updatePremiumButtons()
|
|
|
|
|
+ switch result {
|
|
|
|
|
+ case .success:
|
|
|
|
|
+ self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
|
|
|
|
|
+ self.paywallWindow?.performClose(nil)
|
|
|
|
|
+ self.paywallWindow = nil
|
|
|
|
|
+ case .pending:
|
|
|
|
|
+ self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
|
|
|
|
|
+ case .cancelled:
|
|
|
|
|
+ break
|
|
|
|
|
+ case .failed(let message):
|
|
|
|
|
+ self.showSimpleAlert(title: "Purchase Failed", message: message)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func updatePaywallContinueState(isLoading: Bool) {
|
|
|
|
|
+ if isLoading {
|
|
|
|
|
+ paywallContinueEnabled = false
|
|
|
|
|
+ paywallContinueLabel?.stringValue = "Processing..."
|
|
|
|
|
+ paywallContinueButton?.alphaValue = 0.75
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
|
|
+ paywallContinueEnabled = false
|
|
|
|
|
+ paywallContinueLabel?.stringValue = "Premium Active"
|
|
|
|
|
+ paywallContinueButton?.alphaValue = 0.75
|
|
|
|
|
+ } else {
|
|
|
|
|
+ paywallContinueEnabled = true
|
|
|
|
|
+ paywallContinueLabel?.stringValue = "Continue"
|
|
|
|
|
+ paywallContinueButton?.alphaValue = 1.0
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
|
|
|
|
|
+ let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
|
|
|
|
|
+ let idleBorder = palette.inputBorder
|
|
|
|
|
+ let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
|
|
|
|
|
+ let hoverIdleBackground =
|
|
|
|
|
+ palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
|
|
|
|
|
+ let selectedBackground = palette.isDarkMode
|
|
|
|
|
+ ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
|
|
|
|
|
+ : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
|
|
|
|
|
+ card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
|
|
|
|
|
+ card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
|
|
|
|
|
+ card.layer?.borderWidth = isSelected ? 2 : 1
|
|
|
|
|
+ card.layer?.shadowColor = NSColor.black.cgColor
|
|
|
|
|
+ card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
|
|
|
|
|
+ card.layer?.shadowOffset = CGSize(width: 0, height: -1)
|
|
|
|
|
+ card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// MARK: - Home UI
|
|
// MARK: - Home UI
|
|
|
|
|
|
|
|
private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
|
|
private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
|
|
@@ -1701,7 +2265,9 @@ class ViewController: NSViewController {
|
|
|
rightTopBarCluster.orientation = .horizontal
|
|
rightTopBarCluster.orientation = .horizontal
|
|
|
rightTopBarCluster.spacing = 10
|
|
rightTopBarCluster.spacing = 10
|
|
|
rightTopBarCluster.alignment = .centerY
|
|
rightTopBarCluster.alignment = .centerY
|
|
|
- let upgradeToProButton = makeUpgradeToProButton(action: #selector(topBarPlaceholderTapped))
|
|
|
|
|
|
|
+ let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
|
|
|
|
|
+ topBarPremiumButton = upgradeToProButton
|
|
|
|
|
+ updateTopBarPremiumButton()
|
|
|
|
|
|
|
|
let profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
|
|
let profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
|
|
|
profileChip.isBordered = false
|
|
profileChip.isBordered = false
|
|
@@ -2508,6 +3074,13 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+private extension Array {
|
|
|
|
|
+ subscript(safe index: Int) -> Element? {
|
|
|
|
|
+ guard index >= 0, index < count else { return nil }
|
|
|
|
|
+ return self[index]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
private final class SearchPillTextField: NSTextField {
|
|
private final class SearchPillTextField: NSTextField {
|
|
|
var onFocusChange: ((Bool) -> Void)?
|
|
var onFocusChange: ((Bool) -> Void)?
|
|
|
private(set) var isSearchFocused = false
|
|
private(set) var isSearchFocused = false
|