|
@@ -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
|
|
// MARK: - Action Button
|
|
|
|
|
|
|
|
final class PillButton: NSButton {
|
|
final class PillButton: NSButton {
|