Ver Fonte

Add a three-use free tier with visible remaining-count UI and paywall gating.

Let non-premium users try any feature three times, show how many uses are left across the app, and present the paywall once the trial is exhausted.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 há 3 semanas atrás
pai
commit
3d165e340a

+ 40 - 3
smart_printer/FreeTierManager.swift

@@ -15,12 +15,49 @@ enum AppFeature: Sendable {
 
 @MainActor
 enum FreeTierManager {
+    static let maxFreeUses = 3
+
+    private static let usedCountKey = "FreeTierManager.usedCount"
+
+    static var usedCount: Int {
+        UserDefaults.standard.integer(forKey: usedCountKey)
+    }
+
+    static func remainingUses(isPremium: Bool) -> Int {
+        guard !isPremium else { return maxFreeUses }
+        return max(0, maxFreeUses - usedCount)
+    }
+
     static func canAccess(_ feature: AppFeature, isPremium: Bool) -> Bool {
         if isPremium { return true }
+        return remainingUses(isPremium: false) > 0
+    }
 
-        switch feature {
-        case .printText, .scannerScan, .scanImport, .printContacts, .drawPrint, .ocrFile, .photoPreview, .filePreview, .documentImport:
-            return false
+    static func statusText(remaining: Int) -> String {
+        switch remaining {
+        case 1:
+            return "1 free use left"
+        default:
+            return "\(remaining) free uses left"
         }
     }
+
+    @discardableResult
+    static func recordUseIfNeeded(isPremium: Bool) -> Bool {
+        guard !isPremium else { return true }
+        guard remainingUses(isPremium: false) > 0 else { return false }
+
+        UserDefaults.standard.set(usedCount + 1, forKey: usedCountKey)
+        NotificationCenter.default.post(name: .freeTierDidChange, object: nil)
+        return true
+    }
+
+    static func reset() {
+        UserDefaults.standard.removeObject(forKey: usedCountKey)
+        NotificationCenter.default.post(name: .freeTierDidChange, object: nil)
+    }
+}
+
+extension Notification.Name {
+    static let freeTierDidChange = Notification.Name("freeTierDidChange")
 }

+ 8 - 1
smart_printer/OCRFileView.swift

@@ -149,6 +149,7 @@ final class OCRFileOverlayView: NSView, AppearanceRefreshable {
     private let imageURL: URL
     private let backButton = OCRFileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
     private let titleLabel = NSTextField(labelWithString: "OCR File")
+    private let freeUsesBanner = FreeUsesBannerView()
     private let textContainer = NSView()
     private let textScrollView = NSScrollView()
     private let textView = NSTextView()
@@ -190,6 +191,7 @@ final class OCRFileOverlayView: NSView, AppearanceRefreshable {
         statusLabel.textColor = AppTheme.textSecondary
         backButton.refreshAppearance()
         printButton.refreshAppearance()
+        freeUsesBanner.refreshAppearance()
     }
 
     func present(in parent: NSView) {
@@ -275,6 +277,7 @@ final class OCRFileOverlayView: NSView, AppearanceRefreshable {
 
         addSubview(backButton)
         addSubview(titleLabel)
+        addSubview(freeUsesBanner)
         addSubview(textContainer)
         textContainer.addSubview(textScrollView)
         textContainer.addSubview(loadingIndicator)
@@ -290,9 +293,12 @@ final class OCRFileOverlayView: NSView, AppearanceRefreshable {
             titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
             titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
 
+            freeUsesBanner.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
+            freeUsesBanner.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
+
             textContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
             textContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
-            textContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
+            textContainer.topAnchor.constraint(equalTo: freeUsesBanner.bottomAnchor, constant: 16),
             textContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
 
             textScrollView.leadingAnchor.constraint(equalTo: textContainer.leadingAnchor, constant: 12),
@@ -317,6 +323,7 @@ final class OCRFileOverlayView: NSView, AppearanceRefreshable {
         Task { @MainActor in
             do {
                 let text = try await OCRFileService.recognizeText(from: imageURL)
+                PremiumAccess.recordFreeUseIfNeeded()
                 showRecognizedText(text)
             } catch {
                 showRecognitionError(error)

+ 20 - 0
smart_printer/PaywallView.swift

@@ -836,6 +836,26 @@ enum PremiumAccess {
         return true
     }
 
+    @MainActor
+    @discardableResult
+    static func requireForAction(
+        feature: AppFeature,
+        from window: NSWindow?,
+        returnToHomeOnDismiss: Bool = false
+    ) -> Bool {
+        guard require(feature: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) else {
+            return false
+        }
+        recordFreeUseIfNeeded()
+        return true
+    }
+
+    @MainActor
+    static func recordFreeUseIfNeeded() {
+        guard !StoreManager.shared.isPremium else { return }
+        FreeTierManager.recordUseIfNeeded(isPremium: false)
+    }
+
     @MainActor
     @discardableResult
     static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {

+ 1 - 1
smart_printer/PrintService.swift

@@ -6,7 +6,7 @@ import UniformTypeIdentifiers
 enum PrintService {
     @MainActor
     private static func requirePremium(_ feature: AppFeature, from window: NSWindow?) -> Bool {
-        PremiumAccess.require(feature: feature, from: window ?? NSApp.keyWindow)
+        PremiumAccess.requireForAction(feature: feature, from: window ?? NSApp.keyWindow)
     }
 
     static func printContacts(_ contacts: [PrintableContact], from window: NSWindow? = nil) {

+ 7 - 1
smart_printer/PrintTextView.swift

@@ -18,6 +18,7 @@ final class PrintTextOverlayView: NSView, AppearanceRefreshable {
 
     private let backButton = PrintTextToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
     private let titleLabel = NSTextField(labelWithString: "Print Text")
+    private let freeUsesBanner = FreeUsesBannerView()
     private let textContainer = NSView()
     private let textScrollView = NSScrollView()
     private let textView = NSTextView()
@@ -54,6 +55,7 @@ final class PrintTextOverlayView: NSView, AppearanceRefreshable {
         textContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
         backButton.refreshAppearance()
         printButton.refreshAppearance()
+        freeUsesBanner.refreshAppearance()
     }
 
     func present(in parent: NSView) {
@@ -129,6 +131,7 @@ final class PrintTextOverlayView: NSView, AppearanceRefreshable {
 
         addSubview(backButton)
         addSubview(titleLabel)
+        addSubview(freeUsesBanner)
         addSubview(textContainer)
         textContainer.addSubview(textScrollView)
         addSubview(printButton)
@@ -142,9 +145,12 @@ final class PrintTextOverlayView: NSView, AppearanceRefreshable {
             titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
             titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
 
+            freeUsesBanner.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
+            freeUsesBanner.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
+
             textContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
             textContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
-            textContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
+            textContainer.topAnchor.constraint(equalTo: freeUsesBanner.bottomAnchor, constant: 16),
             textContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
 
             textScrollView.leadingAnchor.constraint(equalTo: textContainer.leadingAnchor, constant: 12),

+ 15 - 1
smart_printer/SettingsView.swift

@@ -123,15 +123,29 @@ final class SettingsView: NSView, AppearanceRefreshable {
             symbolName: "moon.fill",
             title: "Dark Mode",
             isOn: AppSettings.darkModeEnabled,
-            isLast: true
+            isLast: !showsFreeTierReset
         ) { enabled in
             AppSettings.darkModeEnabled = enabled
             AppSettings.applyAppearance()
         })
 
+        if showsFreeTierReset {
+            card.addRow(SettingsActionRow(
+                symbolName: "arrow.counterclockwise",
+                title: "Reset Free Uses",
+                isLast: true
+            ) {
+                FreeTierManager.reset()
+            })
+        }
+
         return card
     }
 
+    private var showsFreeTierReset: Bool {
+        !StoreManager.shared.isPremium
+    }
+
     private func makeAboutCard() -> SettingsGroupCard {
         let card = SettingsGroupCard()
         card.setRows(aboutCardRows())

+ 7 - 0
smart_printer/SidebarView.swift

@@ -12,6 +12,7 @@ final class SidebarView: NSView, AppearanceRefreshable {
     private var appNameLabel: NSTextField!
     private var logoIcon: NSImageView!
     private var premiumCard: SidebarPremiumCardView!
+    private var freeUsesBanner: FreeUsesBannerView!
     var onDestinationSelected: ((SidebarDestination) -> Void)?
     var onPremiumUpgradeTapped: (() -> Void)?
     var onManageSubscriptionTapped: (() -> Void)?
@@ -37,6 +38,7 @@ final class SidebarView: NSView, AppearanceRefreshable {
 
     func refreshPremiumState() {
         premiumCard?.refreshFromStore()
+        freeUsesBanner?.refreshFromStore()
     }
 
     func refreshAppearance() {
@@ -45,6 +47,7 @@ final class SidebarView: NSView, AppearanceRefreshable {
         logoIcon?.image = nil
         logoIcon?.image = NSImage(named: "AppLogo")
         navItems.forEach { $0.refreshAppearance() }
+        freeUsesBanner?.refreshAppearance()
         premiumCard?.refreshAppearance()
     }
 
@@ -97,6 +100,8 @@ final class SidebarView: NSView, AppearanceRefreshable {
             self?.onManageSubscriptionTapped?()
         }
 
+        freeUsesBanner = FreeUsesBannerView()
+
         navItems = [homeItem, scanItem, printItem, settingsItem]
 
         for (index, item) in navItems.enumerated() {
@@ -111,6 +116,7 @@ final class SidebarView: NSView, AppearanceRefreshable {
         for item in navItems {
             navStack.addArrangedSubview(item)
         }
+        bottomNavStack.addArrangedSubview(freeUsesBanner)
         bottomNavStack.addArrangedSubview(premiumCard)
 
         addSubview(logoContainer)
@@ -152,6 +158,7 @@ final class SidebarView: NSView, AppearanceRefreshable {
             scanItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
             printItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
             settingsItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
+            freeUsesBanner.widthAnchor.constraint(equalTo: bottomNavStack.widthAnchor),
             premiumCard.widthAnchor.constraint(equalTo: bottomNavStack.widthAnchor),
 
             divider.trailingAnchor.constraint(equalTo: trailingAnchor),

+ 91 - 0
smart_printer/UIComponents.swift

@@ -743,6 +743,97 @@ private final class ButtonClickTarget: NSObject {
     }
 }
 
+// MARK: - Free Uses Banner
+
+final class FreeUsesBannerView: NSView, AppearanceRefreshable {
+    private let iconView = NSImageView()
+    private let label = NSTextField(labelWithString: "")
+    private var observers: [NSObjectProtocol] = []
+    private var heightConstraint: NSLayoutConstraint!
+
+    init() {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.cornerRadius = 10
+        layer?.borderWidth = 1.5
+        heightConstraint = heightAnchor.constraint(equalToConstant: 36)
+        heightConstraint.isActive = true
+
+        if let image = NSImage(systemSymbolName: "gift.fill", accessibilityDescription: nil) {
+            let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
+            iconView.image = image.withSymbolConfiguration(config)
+        }
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+
+        label.font = AppTheme.mediumFont(size: 13)
+        label.translatesAutoresizingMaskIntoConstraints = false
+        label.setContentHuggingPriority(.required, for: .horizontal)
+        setContentHuggingPriority(.required, for: .horizontal)
+
+        addSubview(iconView)
+        addSubview(label)
+
+        NSLayoutConstraint.activate([
+            iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
+            iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
+            iconView.widthAnchor.constraint(equalToConstant: 16),
+            iconView.heightAnchor.constraint(equalToConstant: 16),
+
+            label.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
+            label.centerYAnchor.constraint(equalTo: centerYAnchor),
+            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
+        ])
+
+        let center = NotificationCenter.default
+        observers = [
+            center.addObserver(forName: .freeTierDidChange, object: nil, queue: .main) { [weak self] _ in
+                self?.refreshFromStore()
+            },
+            center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
+                self?.refreshFromStore()
+            },
+            center.addObserver(forName: .entitlementsDidResolve, object: nil, queue: .main) { [weak self] _ in
+                self?.refreshFromStore()
+            },
+        ]
+
+        refreshFromStore()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    deinit {
+        observers.forEach { NotificationCenter.default.removeObserver($0) }
+    }
+
+    func refreshFromStore() {
+        let isPremium = StoreManager.shared.isPremium
+        let remaining = FreeTierManager.remainingUses(isPremium: isPremium)
+        let shouldShow = !isPremium && remaining > 0
+        isHidden = !shouldShow
+        heightConstraint.constant = shouldShow ? 36 : 0
+        guard shouldShow else { return }
+        label.stringValue = FreeTierManager.statusText(remaining: remaining)
+        invalidateIntrinsicContentSize()
+    }
+
+    override var intrinsicContentSize: NSSize {
+        guard !isHidden else { return NSSize(width: NSView.noIntrinsicMetric, height: 0) }
+        let textWidth = label.intrinsicContentSize.width
+        let width = 12 + 16 + 8 + textWidth + 12
+        return NSSize(width: width, height: heightConstraint.constant)
+    }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.premiumActionBackground.cgColor
+        layer?.borderColor = AppTheme.premiumActionBorder.cgColor
+        iconView.contentTintColor = AppTheme.premiumForeground
+        label.textColor = AppTheme.premiumForeground
+    }
+}
+
 // MARK: - Feature Card
 
 struct FeatureCardData {

+ 27 - 6
smart_printer/ViewController.swift

@@ -31,6 +31,7 @@ class ViewController: NSViewController {
     private var paywallReturnToHomeOnDismiss = true
 
     private var headerView: NSView!
+    private var headerFreeUsesBanner: FreeUsesBannerView!
     private var contentTopBelowHeader: NSLayoutConstraint!
     private var contentTopBelowWindow: NSLayoutConstraint!
 
@@ -118,6 +119,7 @@ class ViewController: NSViewController {
         sidebar?.refreshAppearance()
         view.refreshAppearanceRecursively()
         paywallOverlay?.refreshAppearance()
+        headerFreeUsesBanner?.refreshAppearance()
         photoPreviewOverlay?.refreshAppearance()
         filePreviewOverlay?.refreshAppearance()
         printTextOverlay?.refreshAppearance()
@@ -488,7 +490,7 @@ class ViewController: NSViewController {
 
     private func showDestinationAfterEntitlementCheck(_ destination: SidebarDestination) {
         if destination == .scanAndHome {
-            if StoreManager.shared.isPremium {
+            if StoreManager.shared.isPremium || FreeTierManager.remainingUses(isPremium: false) > 0 {
                 sidebar.select(.scan)
                 showDestinationAfterEntitlementCheck(.scan)
             } else {
@@ -497,14 +499,14 @@ class ViewController: NSViewController {
             return
         }
 
-        if destination == .scan, !StoreManager.shared.isPremium {
+        if destination == .scan, !PremiumAccess.canAccess(.scannerScan) {
             sidebar.select(.home)
             showDestinationContent(.home)
             presentPaywall(returnToHomeOnDismiss: true)
             return
         }
 
-        if destination == .print, !StoreManager.shared.isPremium {
+        if destination == .print, !PremiumAccess.canAccess(.printText) {
             sidebar.select(.home)
             showDestinationContent(.home)
             presentPaywall(returnToHomeOnDismiss: true)
@@ -651,9 +653,11 @@ class ViewController: NSViewController {
         documentView.translatesAutoresizingMaskIntoConstraints = false
 
         let sectionTitle = makeSectionTitle(title, icon: .grid)
+        let freeUsesBanner = FreeUsesBannerView()
         let grid = makeFeatureGrid(features: features, columns: AppTheme.featureGridColumns)
 
         documentView.addSubview(sectionTitle)
+        documentView.addSubview(freeUsesBanner)
         documentView.addSubview(grid)
         scrollView.documentView = documentView
         container.addSubview(scrollView)
@@ -675,9 +679,13 @@ class ViewController: NSViewController {
             sectionTitle.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
             sectionTitle.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8),
 
+            freeUsesBanner.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
+            freeUsesBanner.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
+            freeUsesBanner.topAnchor.constraint(equalTo: sectionTitle.bottomAnchor, constant: 12),
+
             grid.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
             grid.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
-            grid.topAnchor.constraint(equalTo: sectionTitle.bottomAnchor, constant: 16),
+            grid.topAnchor.constraint(equalTo: freeUsesBanner.bottomAnchor, constant: 16),
             grid.bottomAnchor.constraint(equalTo: documentView.bottomAnchor, constant: -40),
         ])
 
@@ -692,11 +700,18 @@ class ViewController: NSViewController {
         titleLabel.alignment = .center
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
+        headerFreeUsesBanner = FreeUsesBannerView()
+        headerFreeUsesBanner.translatesAutoresizingMaskIntoConstraints = false
+
         header.addSubview(titleLabel)
+        header.addSubview(headerFreeUsesBanner)
 
         NSLayoutConstraint.activate([
             titleLabel.centerXAnchor.constraint(equalTo: header.centerXAnchor),
-            titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 2),
+            titleLabel.centerYAnchor.constraint(equalTo: header.centerYAnchor),
+
+            headerFreeUsesBanner.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -AppTheme.contentPadding),
+            headerFreeUsesBanner.centerYAnchor.constraint(equalTo: header.centerYAnchor),
         ])
 
         return header
@@ -739,9 +754,11 @@ class ViewController: NSViewController {
         let documentView = FlippedDocumentView()
         documentView.translatesAutoresizingMaskIntoConstraints = false
 
+        let freeUsesBanner = FreeUsesBannerView()
         let quickStartSection = makeQuickStartSection()
         let createPrintSection = makeCreatePrintSection()
 
+        documentView.addSubview(freeUsesBanner)
         documentView.addSubview(quickStartSection)
         documentView.addSubview(createPrintSection)
         scrollView.documentView = documentView
@@ -754,9 +771,13 @@ class ViewController: NSViewController {
             documentView.trailingAnchor.constraint(equalTo: contentGuide.trailingAnchor),
             documentView.widthAnchor.constraint(equalTo: contentGuide.widthAnchor),
 
+            freeUsesBanner.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
+            freeUsesBanner.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
+            freeUsesBanner.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 12),
+
             quickStartSection.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
             quickStartSection.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
-            quickStartSection.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 20),
+            quickStartSection.topAnchor.constraint(equalTo: freeUsesBanner.bottomAnchor, constant: 16),
 
             createPrintSection.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
             createPrintSection.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),