Procházet zdrojové kódy

Redesign the sidebar premium card and return users to Home after purchase.

Replace the Premium nav row with a centered Gramora-style card, widen the sidebar for subscription actions, and dismiss the paywall automatically when premium unlocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 před 4 týdny
rodič
revize
f26cdfe5ed

+ 37 - 3
smart_printer/AppTheme.swift

@@ -2,12 +2,12 @@ import Cocoa
 import ObjectiveC
 
 enum AppTheme {
-    static let windowWidth: CGFloat = 760
+    static let windowWidth: CGFloat = 800
     static let windowHeight: CGFloat = 720
-    static let windowMinWidth: CGFloat = 650
+    static let windowMinWidth: CGFloat = 690
     static let windowMinHeight: CGFloat = windowHeight
 
-    static let sidebarWidth: CGFloat = 200
+    static let sidebarWidth: CGFloat = 240
     static let contentPadding: CGFloat = 20
     static let quickStartSpacing: CGFloat = 12
     static let featureGridSpacing: CGFloat = 10
@@ -137,6 +137,40 @@ enum AppTheme {
             : NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
     }
 
+    static var premiumCardBorder: NSColor {
+        isDark
+            ? NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 0.28)
+            : NSColor(red: 0.88, green: 0.82, blue: 0.98, alpha: 1)
+    }
+
+    static let premiumCrown = NSColor(red: 0.95, green: 0.75, blue: 0.20, alpha: 1)
+
+    static var premiumActionBackground: NSColor { cardBackground }
+
+    static var premiumActionHoverBackground: NSColor {
+        isDark
+            ? NSColor(red: 0.22, green: 0.20, blue: 0.30, alpha: 1)
+            : NSColor(red: 0.98, green: 0.96, blue: 1.0, alpha: 1)
+    }
+
+    static var premiumActionBorder: NSColor { paywallBorder }
+
+    static var premiumActionBorderHover: NSColor {
+        isDark
+            ? NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 0.45)
+            : NSColor(red: 0.78, green: 0.68, blue: 0.96, alpha: 1)
+    }
+
+    static var premiumButtonBackground: NSColor { purple }
+
+    static var premiumButtonHoverBackground: NSColor {
+        isDark
+            ? NSColor(red: 0.62, green: 0.45, blue: 1.0, alpha: 1)
+            : NSColor(red: 0.48, green: 0.28, blue: 0.90, alpha: 1)
+    }
+
+    static let premiumButtonForeground = NSColor.white
+
     static let blue = NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
     static let quickStartBlueLight = NSColor(red: 0.88, green: 0.93, blue: 1.0, alpha: 1)
     static let quickStartCardSubtitle = NSColor(calibratedWhite: 0.48, alpha: 1)

+ 2 - 0
smart_printer/PaywallView.swift

@@ -1339,6 +1339,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
 
 final class PaywallOverlayView: NSView, AppearanceRefreshable {
     var onDismiss: (() -> Void)?
+    var onPurchaseSucceeded: (() -> Void)?
 
     private let paywallView: PaywallView
     private let blurView = NSVisualEffectView()
@@ -1384,6 +1385,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
         paywallView.onClose = { [weak self] in self?.dismiss() }
         paywallView.onPurchaseSucceeded = { [weak self] in
             self?.paywallView.refreshStoreState()
+            self?.onPurchaseSucceeded?()
         }
 
         addSubview(blurView)

+ 38 - 13
smart_printer/SidebarView.swift

@@ -10,7 +10,10 @@ enum SidebarDestination: Int, CaseIterable {
 
 final class SidebarView: NSView, AppearanceRefreshable {
     private var appNameLabel: NSTextField!
+    private var premiumCard: SidebarPremiumCardView!
     var onDestinationSelected: ((SidebarDestination) -> Void)?
+    var onPremiumUpgradeTapped: (() -> Void)?
+    var onManageSubscriptionTapped: (() -> Void)?
 
     private var navItems: [SidebarNavItem] = []
 
@@ -27,13 +30,28 @@ final class SidebarView: NSView, AppearanceRefreshable {
 
     func select(_ destination: SidebarDestination) {
         for (index, item) in navItems.enumerated() {
-            item.isSelected = index == destination.rawValue
+            item.isSelected = navDestination(at: index) == destination
         }
     }
 
+    func refreshPremiumState() {
+        premiumCard?.update(isPremium: StoreManager.shared.isPremium)
+    }
+
     func refreshAppearance() {
         layer?.backgroundColor = AppTheme.sidebarBackground.cgColor
         appNameLabel?.refreshThemeLabelColor()
+        premiumCard?.refreshAppearance()
+    }
+
+    private func navDestination(at index: Int) -> SidebarDestination {
+        switch index {
+        case 0: return .home
+        case 1: return .scan
+        case 2: return .print
+        case 3: return .settings
+        default: return .home
+        }
     }
 
     private func setup() {
@@ -57,31 +75,38 @@ final class SidebarView: NSView, AppearanceRefreshable {
 
         let bottomNavStack = NSStackView()
         bottomNavStack.orientation = .vertical
-        bottomNavStack.spacing = 4
+        bottomNavStack.spacing = 12
         bottomNavStack.alignment = .leading
         bottomNavStack.translatesAutoresizingMaskIntoConstraints = false
 
         let homeItem = SidebarNavItem(title: "Home", symbolName: "house.fill", isSelected: true)
         let scanItem = SidebarNavItem(title: "Scan", symbolName: "viewfinder")
         let printItem = SidebarNavItem(title: "Print", symbolName: "printer")
-        let scanAndHomeItem = SidebarNavItem(title: "Premium", symbolName: "diamond.fill", accent: .premium)
         let settingsItem = SidebarNavItem(title: "Settings", symbolName: "gearshape")
 
-        let mainNavItems = [homeItem, scanItem, printItem, scanAndHomeItem]
-        navItems = mainNavItems + [settingsItem]
+        premiumCard = SidebarPremiumCardView()
+        premiumCard.onUpgradeTapped = { [weak self] in
+            self?.onPremiumUpgradeTapped?()
+        }
+        premiumCard.onManageSubscriptionTapped = { [weak self] in
+            self?.onManageSubscriptionTapped?()
+        }
+
+        navItems = [homeItem, scanItem, printItem, settingsItem]
 
         for (index, item) in navItems.enumerated() {
             item.onClick = { [weak self] in
-                guard let self, let destination = SidebarDestination(rawValue: index) else { return }
+                guard let self else { return }
+                let destination = self.navDestination(at: index)
                 self.select(destination)
                 self.onDestinationSelected?(destination)
             }
         }
 
-        for item in mainNavItems {
+        for item in navItems {
             navStack.addArrangedSubview(item)
         }
-        bottomNavStack.addArrangedSubview(settingsItem)
+        bottomNavStack.addArrangedSubview(premiumCard)
 
         addSubview(logoContainer)
         logoContainer.addSubview(logoIcon)
@@ -114,15 +139,15 @@ final class SidebarView: NSView, AppearanceRefreshable {
             navStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
             navStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
 
-            bottomNavStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
-            bottomNavStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
-            bottomNavStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
+            bottomNavStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
+            bottomNavStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
+            bottomNavStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20),
 
             homeItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
             scanItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
             printItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
-            scanAndHomeItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
-            settingsItem.widthAnchor.constraint(equalTo: bottomNavStack.widthAnchor),
+            settingsItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
+            premiumCard.widthAnchor.constraint(equalTo: bottomNavStack.widthAnchor),
 
             divider.trailingAnchor.constraint(equalTo: trailingAnchor),
             divider.topAnchor.constraint(equalTo: topAnchor),

+ 236 - 0
smart_printer/UIComponents.swift

@@ -214,6 +214,242 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
     }
 }
 
+// MARK: - Sidebar Premium Card
+
+final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
+    var onUpgradeTapped: (() -> Void)?
+    var onManageSubscriptionTapped: (() -> Void)?
+
+    private let cardContainer = NSView()
+    private let crownIcon = NSImageView()
+    private let titleLabel = NSTextField.labelWithTheme("Unlock Premium", style: .primary, font: AppTheme.semiboldFont(size: 12))
+    private let descriptionLabel = NSTextField.wrappingThemeLabel(
+        "Get unlimited scans, printing, OCR and all premium features.",
+        style: .secondary,
+        font: AppTheme.regularFont(size: 10)
+    )
+    private let actionButton = PremiumCardActionButton()
+
+    private var isPremium = false
+
+    init() {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        setup()
+        update(isPremium: StoreManager.shared.isPremium)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func update(isPremium: Bool) {
+        self.isPremium = isPremium
+        titleLabel.stringValue = isPremium ? "Smart Printer Pro" : "Unlock Premium"
+        descriptionLabel.stringValue = isPremium
+            ? "You have unlimited scans, printing, OCR and all premium features."
+            : "Get unlimited scans, printing, OCR and all premium features."
+        actionButton.configure(
+            title: isPremium ? "Manage Subscription" : "Upgrade Now",
+            showsArrow: !isPremium
+        )
+        refreshAppearance()
+    }
+
+    func refreshAppearance() {
+        cardContainer.layer?.backgroundColor = AppTheme.premiumBackground.cgColor
+        cardContainer.layer?.borderColor = AppTheme.premiumCardBorder.cgColor
+        crownIcon.contentTintColor = AppTheme.premiumCrown
+        titleLabel.refreshThemeLabelColor()
+        descriptionLabel.refreshThemeLabelColor()
+        actionButton.refreshAppearance()
+    }
+
+    private func setup() {
+        cardContainer.translatesAutoresizingMaskIntoConstraints = false
+        cardContainer.wantsLayer = true
+        cardContainer.layer?.cornerRadius = 10
+        cardContainer.layer?.borderWidth = 1
+
+        crownIcon.translatesAutoresizingMaskIntoConstraints = false
+        if let image = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium") {
+            let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
+            crownIcon.image = image.withSymbolConfiguration(config)
+        }
+
+        titleLabel.alignment = .center
+        descriptionLabel.alignment = .center
+
+        actionButton.translatesAutoresizingMaskIntoConstraints = false
+        actionButton.onClick = { [weak self] in
+            guard let self else { return }
+            if self.isPremium {
+                self.onManageSubscriptionTapped?()
+            } else {
+                self.onUpgradeTapped?()
+            }
+        }
+
+        let contentStack = NSStackView(views: [crownIcon, titleLabel, descriptionLabel, actionButton])
+        contentStack.orientation = .vertical
+        contentStack.alignment = .centerX
+        contentStack.spacing = 6
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
+        contentStack.setCustomSpacing(4, after: crownIcon)
+        contentStack.setCustomSpacing(6, after: titleLabel)
+        contentStack.setCustomSpacing(8, after: descriptionLabel)
+
+        cardContainer.addSubview(contentStack)
+        addSubview(cardContainer)
+
+        NSLayoutConstraint.activate([
+            cardContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
+            cardContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
+            cardContainer.topAnchor.constraint(equalTo: topAnchor),
+            cardContainer.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            contentStack.leadingAnchor.constraint(equalTo: cardContainer.leadingAnchor, constant: 10),
+            contentStack.trailingAnchor.constraint(equalTo: cardContainer.trailingAnchor, constant: -10),
+            contentStack.topAnchor.constraint(equalTo: cardContainer.topAnchor, constant: 10),
+            contentStack.bottomAnchor.constraint(equalTo: cardContainer.bottomAnchor, constant: -10),
+
+            crownIcon.widthAnchor.constraint(equalToConstant: 18),
+            crownIcon.heightAnchor.constraint(equalToConstant: 18),
+
+            descriptionLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
+            actionButton.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
+            actionButton.heightAnchor.constraint(equalToConstant: 32),
+        ])
+    }
+}
+
+private final class PremiumCardActionButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let titleLabel = NSTextField(labelWithString: "")
+    private let arrowView = NSImageView()
+    private let contentStack = NSStackView()
+    private var hoverTracker: HoverTracker?
+    private var isHovered = false
+    private var showsArrow = true
+
+    init() {
+        super.init(frame: .zero)
+        wantsLayer = true
+        layer?.cornerRadius = 7
+        layer?.borderWidth = 1
+
+        titleLabel.font = AppTheme.semiboldFont(size: 11)
+        titleLabel.themeLabelStyle = .primary
+        titleLabel.textColor = AppTheme.textPrimary
+
+        if let image = NSImage(systemSymbolName: "arrow.up.right", accessibilityDescription: nil) {
+            let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
+            arrowView.image = image.withSymbolConfiguration(config)
+        }
+        arrowView.contentTintColor = AppTheme.textPrimary
+        arrowView.translatesAutoresizingMaskIntoConstraints = false
+
+        contentStack.orientation = .horizontal
+        contentStack.spacing = 6
+        contentStack.alignment = .centerY
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
+        contentStack.addArrangedSubview(titleLabel)
+        contentStack.addArrangedSubview(arrowView)
+
+        addSubview(contentStack)
+
+        NSLayoutConstraint.activate([
+            contentStack.centerXAnchor.constraint(equalTo: centerXAnchor),
+            contentStack.centerYAnchor.constraint(equalTo: centerYAnchor),
+            arrowView.widthAnchor.constraint(equalToConstant: 12),
+            arrowView.heightAnchor.constraint(equalToConstant: 12),
+        ])
+
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.isHovered = hovering
+            self?.refreshAppearance()
+        }
+
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func configure(title: String, showsArrow: Bool) {
+        titleLabel.stringValue = title
+        self.showsArrow = showsArrow
+        arrowView.isHidden = !showsArrow
+        refreshAppearance()
+    }
+
+    func refreshAppearance() {
+        let usesFilledPremiumStyle = !showsArrow
+
+        if usesFilledPremiumStyle {
+            layer?.backgroundColor = (isHovered ? AppTheme.premiumButtonHoverBackground : AppTheme.premiumButtonBackground).cgColor
+            layer?.borderColor = NSColor.clear.cgColor
+            layer?.borderWidth = 0
+            titleLabel.textColor = AppTheme.premiumButtonForeground
+            arrowView.contentTintColor = AppTheme.premiumButtonForeground
+            layer?.shadowColor = AppTheme.purple.cgColor
+            layer?.shadowOpacity = isHovered ? 0.35 : 0.22
+        } else {
+            layer?.backgroundColor = (isHovered ? AppTheme.premiumActionHoverBackground : AppTheme.premiumActionBackground).cgColor
+            layer?.borderColor = (isHovered ? AppTheme.premiumActionBorderHover : AppTheme.premiumActionBorder).cgColor
+            layer?.borderWidth = 1
+            let foreground = isHovered ? AppTheme.premiumForeground : AppTheme.textPrimary
+            titleLabel.textColor = foreground
+            arrowView.contentTintColor = foreground
+            layer?.shadowColor = NSColor.black.cgColor
+            layer?.shadowOpacity = isHovered ? 0.08 : 0.03
+        }
+
+        layer?.shadowOffset = NSSize(width: 0, height: isHovered ? -2 : -1)
+        layer?.shadowRadius = isHovered ? 4 : 2
+        animateHover {
+            self.layer?.transform = isHovered
+                ? CATransform3DMakeScale(1.02, 1.02, 1)
+                : CATransform3DIdentity
+        }
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        onClick?()
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}
+
+private extension NSTextField {
+    static func labelWithTheme(_ string: String, style: ThemeLabelStyle, font: NSFont) -> NSTextField {
+        let label = NSTextField(labelWithString: string)
+        label.font = font
+        label.themeLabelStyle = style
+        label.textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
+        label.translatesAutoresizingMaskIntoConstraints = false
+        return label
+    }
+
+    static func wrappingThemeLabel(_ string: String, style: ThemeLabelStyle, font: NSFont) -> NSTextField {
+        let label = NSTextField(wrappingLabelWithString: string)
+        label.font = font
+        label.themeLabelStyle = style
+        label.textColor = style == .secondary ? AppTheme.textSecondary : AppTheme.textPrimary
+        label.isEditable = false
+        label.isSelectable = false
+        label.isBordered = false
+        label.drawsBackground = false
+        label.lineBreakMode = .byWordWrapping
+        label.maximumNumberOfLines = 0
+        label.translatesAutoresizingMaskIntoConstraints = false
+        return label
+    }
+}
+
 // MARK: - Action Button
 
 final class PillButton: NSButton {

+ 26 - 0
smart_printer/ViewController.swift

@@ -76,7 +76,23 @@ class ViewController: NSViewController {
     @objc private func premiumStatusDidChange() {
         Task { @MainActor in
             paywallOverlay?.refreshStoreState()
+            sidebar?.refreshPremiumState()
+            if StoreManager.shared.isPremium {
+                navigateHomeAfterPremiumUnlock()
+            }
+        }
+    }
+
+    private func navigateHomeAfterPremiumUnlock() {
+        guard paywallOverlay != nil else { return }
+        paywallOverlay?.onDismiss = { [weak self] in
+            guard let self else { return }
+            self.setTrafficLightsHidden(false)
+            self.paywallOverlay = nil
+            self.sidebar.select(.home)
+            self.showDestination(.home)
         }
+        paywallOverlay?.dismiss()
     }
 
     private func refreshAppearance() {
@@ -335,6 +351,13 @@ class ViewController: NSViewController {
         sidebar.onDestinationSelected = { [weak self] destination in
             self?.showDestination(destination)
         }
+        sidebar.onPremiumUpgradeTapped = { [weak self] in
+            self?.showDestination(.scanAndHome)
+        }
+        sidebar.onManageSubscriptionTapped = {
+            StoreManager.shared.showManageSubscriptions()
+        }
+        sidebar.refreshPremiumState()
 
         NSLayoutConstraint.activate([
             sidebar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
@@ -464,6 +487,9 @@ class ViewController: NSViewController {
                 self.showDestination(.home)
             }
         }
+        overlay.onPurchaseSucceeded = { [weak self] in
+            self?.navigateHomeAfterPremiumUnlock()
+        }
         overlay.present(in: view)
         paywallOverlay = overlay
     }