Эх сурвалжийг харах

Limit free AI uses and intro trials to users who have never purchased premium.

Refunded or lapsed subscribers forfeit the free tier, while sandbox transaction deletion resets eligibility for testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 долоо хоног өмнө
parent
commit
7bd45c8f13

+ 3 - 2
App AI for Reddit/Managers/AIConfiguration.swift

@@ -20,7 +20,8 @@ enum AIConfiguration {
 
 
     private static var cachedAPIKey: String?
     private static var cachedAPIKey: String?
 
 
-    static func usesLiveAI(hasPremiumAccess: Bool) -> Bool {
-        hasPremiumAccess && apiKey != nil
+    static func usesLiveAI(hasPremiumAccess: Bool, canUseFreeTrial: Bool = false) -> Bool {
+        guard apiKey != nil else { return false }
+        return hasPremiumAccess || canUseFreeTrial
     }
     }
 }
 }

+ 50 - 0
App AI for Reddit/Managers/AIFreeUsageManager.swift

@@ -0,0 +1,50 @@
+import Foundation
+
+@MainActor
+@Observable
+final class AIFreeUsageManager {
+    static let shared = AIFreeUsageManager()
+    static let maxFreeUses = 3
+
+    private static let usedCountKey = "AIFreeUsageManager.usedCount"
+
+    private(set) var usedCount: Int
+
+    var remainingUses: Int {
+        max(0, Self.maxFreeUses - usedCount)
+    }
+
+    var hasExhaustedFreeUses: Bool {
+        usedCount >= Self.maxFreeUses
+    }
+
+    func canUseFreeLiveAI(hasEverPurchasedPremium: Bool) -> Bool {
+        !hasEverPurchasedPremium && remainingUses > 0
+    }
+
+    private init() {
+        usedCount = UserDefaults.standard.integer(forKey: Self.usedCountKey)
+    }
+
+    func recordUse(for tool: AIHistoryToolKind) {
+        guard usedCount < Self.maxFreeUses else { return }
+        usedCount += 1
+        persistUsedCount()
+    }
+
+    /// Restores the free AI quota for users who have never purchased premium (e.g. sandbox transaction reset).
+    func resetFreeUses() {
+        usedCount = 0
+        persistUsedCount()
+    }
+
+    /// Permanently removes free AI access after a purchase or refund.
+    func forfeitFreeUses() {
+        usedCount = Self.maxFreeUses
+        persistUsedCount()
+    }
+
+    private func persistUsedCount() {
+        UserDefaults.standard.set(usedCount, forKey: Self.usedCountKey)
+    }
+}

+ 51 - 14
App AI for Reddit/Managers/SubscriptionManager.swift

@@ -61,6 +61,14 @@ final class SubscriptionManager: ObservableObject {
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
     }
     }
 
 
+    var isEligibleForFreeAITier: Bool {
+        !hasEverPurchasedPremium
+    }
+
+    var isEligibleForIntroTrial: Bool {
+        !hasEverPurchasedPremium && !hasPremiumAccess
+    }
+
     private var inFlightProductLoad: Task<Void, Never>?
     private var inFlightProductLoad: Task<Void, Never>?
     private var initialEntitlementTask: Task<Void, Never>?
     private var initialEntitlementTask: Task<Void, Never>?
     private var entitlementRefreshTask: Task<Void, Never>?
     private var entitlementRefreshTask: Task<Void, Never>?
@@ -352,9 +360,14 @@ final class SubscriptionManager: ObservableObject {
         let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
         let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
         activePremiumPlan = finalPlan
         activePremiumPlan = finalPlan
         hasPremiumAccess = finalPlan != nil
         hasPremiumAccess = finalPlan != nil
+
+        if hasPremiumAccess {
+            markAsHavingPurchasedPremium()
+        }
+
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
         persistPremiumCache()
         persistPremiumCache()
-        await refreshPurchaseHistory()
+        await syncPurchaseHistory()
         await refreshTrialOffers()
         await refreshTrialOffers()
     }
     }
 
 
@@ -403,6 +416,8 @@ final class SubscriptionManager: ObservableObject {
         activePremiumPlan = nil
         activePremiumPlan = nil
         hasPremiumAccess = false
         hasPremiumAccess = false
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
+        AIFreeUsageManager.shared.forfeitFreeUses()
         persistPremiumCache()
         persistPremiumCache()
     }
     }
 
 
@@ -436,15 +451,48 @@ final class SubscriptionManager: ObservableObject {
         hasEverPurchasedPremium = true
         hasEverPurchasedPremium = true
         trialDisplayByPlan = [:]
         trialDisplayByPlan = [:]
         UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
         UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+        AIFreeUsageManager.shared.forfeitFreeUses()
+    }
+
+    /// Aligns local purchase flags with StoreKit history.
+    /// - Purchases/refunds still on record → user has bought before; no free tier.
+    /// - No transactions at all (sandbox delete) → treat as a new user with 3 free uses.
+    private func syncPurchaseHistory() async {
+        var hasRecordedPurchase = false
+
+        for await result in Transaction.all {
+            guard case .verified(let transaction) = result else { continue }
+            guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
+            hasRecordedPurchase = true
+            break
+        }
+
+        if hasRecordedPurchase {
+            if !hasEverPurchasedPremium {
+                hasEverPurchasedPremium = true
+                UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+                AIFreeUsageManager.shared.forfeitFreeUses()
+            }
+        } else if hasEverPurchasedPremium {
+            resetToNeverPurchasedState()
+        }
+    }
+
+    private func resetToNeverPurchasedState() {
+        hasEverPurchasedPremium = false
+        UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
+        UserDefaults.standard.removeObject(forKey: "SubscriptionManager.hadPremiumAccess")
+        AIFreeUsageManager.shared.resetFreeUses()
+        trialDisplayByPlan = [:]
     }
     }
 
 
     private func refreshTrialOffers() async {
     private func refreshTrialOffers() async {
-        guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
+        guard isEligibleForIntroTrial else {
             trialDisplayByPlan = [:]
             trialDisplayByPlan = [:]
             return
             return
         }
         }
 
 
-        if hasPremiumAccess {
+        guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
             trialDisplayByPlan = [:]
             trialDisplayByPlan = [:]
             return
             return
         }
         }
@@ -474,17 +522,6 @@ final class SubscriptionManager: ObservableObject {
         return nil
         return nil
     }
     }
 
 
-    private func refreshPurchaseHistory() async {
-        if hasEverPurchasedPremium { return }
-
-        for await result in Transaction.all {
-            guard case .verified(let transaction) = result else { continue }
-            guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
-            markAsHavingPurchasedPremium()
-            return
-        }
-    }
-
     private func persistPremiumCache() {
     private func persistPremiumCache() {
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
         if let plan = activePremiumPlan?.rawValue {
         if let plan = activePremiumPlan?.rawValue {

+ 2 - 2
App AI for Reddit/Services/CommentGenerationService.swift

@@ -5,8 +5,8 @@ protocol CommentGenerationServiceProtocol: Sendable {
 }
 }
 
 
 enum CommentGenerationServiceFactory {
 enum CommentGenerationServiceFactory {
-    static func make(hasPremiumAccess: Bool) -> any CommentGenerationServiceProtocol {
-        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
+    static func make(usesLiveAI: Bool) -> any CommentGenerationServiceProtocol {
+        if usesLiveAI, let apiKey = AIConfiguration.apiKey {
             return OpenAICommentGenerationService(apiKey: apiKey)
             return OpenAICommentGenerationService(apiKey: apiKey)
         }
         }
         return MockCommentGenerationService()
         return MockCommentGenerationService()

+ 2 - 2
App AI for Reddit/Services/PostGenerationService.swift

@@ -5,8 +5,8 @@ protocol PostGenerationServiceProtocol: Sendable {
 }
 }
 
 
 enum PostGenerationServiceFactory {
 enum PostGenerationServiceFactory {
-    static func make(hasPremiumAccess: Bool) -> any PostGenerationServiceProtocol {
-        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
+    static func make(usesLiveAI: Bool) -> any PostGenerationServiceProtocol {
+        if usesLiveAI, let apiKey = AIConfiguration.apiKey {
             return OpenAIPostGenerationService(apiKey: apiKey)
             return OpenAIPostGenerationService(apiKey: apiKey)
         }
         }
         return MockPostGenerationService()
         return MockPostGenerationService()

+ 2 - 2
App AI for Reddit/Services/TitleOptimizationService.swift

@@ -5,8 +5,8 @@ protocol TitleOptimizationServiceProtocol: Sendable {
 }
 }
 
 
 enum TitleOptimizationServiceFactory {
 enum TitleOptimizationServiceFactory {
-    static func make(hasPremiumAccess: Bool) -> any TitleOptimizationServiceProtocol {
-        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
+    static func make(usesLiveAI: Bool) -> any TitleOptimizationServiceProtocol {
+        if usesLiveAI, let apiKey = AIConfiguration.apiKey {
             return OpenAITitleOptimizationService(apiKey: apiKey)
             return OpenAITitleOptimizationService(apiKey: apiKey)
         }
         }
         return MockTitleOptimizationService()
         return MockTitleOptimizationService()

+ 65 - 5
App AI for Reddit/ViewModels/CommentWriterViewModel.swift

@@ -14,26 +14,46 @@ final class CommentWriterViewModel {
     var showOverwriteConfirmation = false
     var showOverwriteConfirmation = false
 
 
     private var hasPremiumAccess = false
     private var hasPremiumAccess = false
+    private var hasEverPurchasedPremium = false
     private let injectedGenerationService: (any CommentGenerationServiceProtocol)?
     private let injectedGenerationService: (any CommentGenerationServiceProtocol)?
     private let historyManager: AIHistoryManager
     private let historyManager: AIHistoryManager
+    private let freeUsageManager: AIFreeUsageManager
+    var onPaywallRequired: (() -> Void)?
+    private(set) var remainingFreeAIUses: Int
     private let emptyDraft = CommentDraft()
     private let emptyDraft = CommentDraft()
 
 
     init(
     init(
         hasPremiumAccess: Bool = false,
         hasPremiumAccess: Bool = false,
         generationService: (any CommentGenerationServiceProtocol)? = nil,
         generationService: (any CommentGenerationServiceProtocol)? = nil,
-        historyManager: AIHistoryManager = .shared
+        historyManager: AIHistoryManager = .shared,
+        freeUsageManager: AIFreeUsageManager = .shared
     ) {
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedGenerationService = generationService
         self.injectedGenerationService = generationService
         self.historyManager = historyManager
         self.historyManager = historyManager
+        self.freeUsageManager = freeUsageManager
+        self.remainingFreeAIUses = freeUsageManager.remainingUses
     }
     }
 
 
-    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+    func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
         self.hasPremiumAccess = hasPremiumAccess
         self.hasPremiumAccess = hasPremiumAccess
+        self.hasEverPurchasedPremium = hasEverPurchasedPremium
+        refreshFreeUsageState()
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        setSubscriptionState(
+            hasPremiumAccess: hasPremiumAccess,
+            hasEverPurchasedPremium: hasEverPurchasedPremium
+        )
+    }
+
+    func refreshFreeUsageState() {
+        remainingFreeAIUses = freeUsageManager.remainingUses
     }
     }
 
 
     private var generationService: any CommentGenerationServiceProtocol {
     private var generationService: any CommentGenerationServiceProtocol {
-        injectedGenerationService ?? CommentGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+        injectedGenerationService ?? CommentGenerationServiceFactory.make(usesLiveAI: usesLiveAI)
     }
     }
 
 
     var formattedSubreddit: String {
     var formattedSubreddit: String {
@@ -43,7 +63,27 @@ final class CommentWriterViewModel {
     }
     }
 
 
     var usesLiveAI: Bool {
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
+    var generationEngineSubtitle: String {
+        if usesLiveAI {
+            if hasPremiumAccess {
+                return "Write Reddit-ready comments with AI"
+            }
+            let remaining = remainingFreeAIUses
+            let useLabel = remaining == 1 ? "use" : "uses"
+            return "Write Reddit-ready comments with AI (\(remaining) free \(useLabel) left)"
+        }
+        if hasEverPurchasedPremium {
+            return "Upgrade to Premium for unlimited AI generations"
+        }
+        return "Write Reddit-ready comments with AI templates"
     }
     }
 
 
     var hasDraftChanges: Bool {
     var hasDraftChanges: Bool {
@@ -99,6 +139,11 @@ final class CommentWriterViewModel {
     func generateComment() async {
     func generateComment() async {
         guard canGenerate else { return }
         guard canGenerate else { return }
 
 
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         if needsOverwriteConfirmation {
         if needsOverwriteConfirmation {
             showOverwriteConfirmation = true
             showOverwriteConfirmation = true
             return
             return
@@ -165,10 +210,17 @@ final class CommentWriterViewModel {
     }
     }
 
 
     private func performGenerate(replaceExisting: Bool) async {
     private func performGenerate(replaceExisting: Bool) async {
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         isGenerating = true
         isGenerating = true
         errorMessage = nil
         errorMessage = nil
         successMessage = nil
         successMessage = nil
 
 
+        let usedLiveAI = usesLiveAI
+
         do {
         do {
             let result = try await generationService.generateComment(from: draft)
             let result = try await generationService.generateComment(from: draft)
             if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty {
             if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty {
@@ -177,12 +229,16 @@ final class CommentWriterViewModel {
             variants = result.variants
             variants = result.variants
             selectedVariantID = result.variants.first?.id
             selectedVariantID = result.variants.first?.id
             selectedTab = .preview
             selectedTab = .preview
+            if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
+                freeUsageManager.recordUse(for: .commentWriter)
+                refreshFreeUsageState()
+            }
             historyManager.save(AIHistoryEntry.fromCommentWriter(
             historyManager.save(AIHistoryEntry.fromCommentWriter(
                 draft: draft,
                 draft: draft,
                 variants: variants,
                 variants: variants,
                 selectedVariantID: selectedVariantID
                 selectedVariantID: selectedVariantID
             ))
             ))
-            let engine = usesLiveAI ? "AI" : "local AI templates"
+            let engine = usedLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
             successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
         } catch {
         } catch {
             errorMessage = UserFacingError.message(for: error)
             errorMessage = UserFacingError.message(for: error)
@@ -190,4 +246,8 @@ final class CommentWriterViewModel {
 
 
         isGenerating = false
         isGenerating = false
     }
     }
+
+    private func requiresPaywallForGeneration() -> Bool {
+        !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
+    }
 }
 }

+ 88 - 6
App AI for Reddit/ViewModels/PostGeneratorViewModel.swift

@@ -13,9 +13,13 @@ final class PostGeneratorViewModel {
     var showOverwriteConfirmation = false
     var showOverwriteConfirmation = false
 
 
     private var hasPremiumAccess = false
     private var hasPremiumAccess = false
+    private var hasEverPurchasedPremium = false
     private var generationService: any PostGenerationServiceProtocol
     private var generationService: any PostGenerationServiceProtocol
     private let injectedGenerationService: (any PostGenerationServiceProtocol)?
     private let injectedGenerationService: (any PostGenerationServiceProtocol)?
     private let historyManager: AIHistoryManager
     private let historyManager: AIHistoryManager
+    private let freeUsageManager: AIFreeUsageManager
+    var onPaywallRequired: (() -> Void)?
+    private(set) var remainingFreeAIUses: Int
     private var imageAccessURL: URL?
     private var imageAccessURL: URL?
     private var isAccessingImageResource = false
     private var isAccessingImageResource = false
     private let emptyDraft = PostDraft()
     private let emptyDraft = PostDraft()
@@ -23,22 +27,60 @@ final class PostGeneratorViewModel {
     init(
     init(
         hasPremiumAccess: Bool = false,
         hasPremiumAccess: Bool = false,
         generationService: (any PostGenerationServiceProtocol)? = nil,
         generationService: (any PostGenerationServiceProtocol)? = nil,
-        historyManager: AIHistoryManager = .shared
+        historyManager: AIHistoryManager = .shared,
+        freeUsageManager: AIFreeUsageManager = .shared
     ) {
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedGenerationService = generationService
         self.injectedGenerationService = generationService
         self.historyManager = historyManager
         self.historyManager = historyManager
+        self.freeUsageManager = freeUsageManager
+        self.remainingFreeAIUses = freeUsageManager.remainingUses
+        self.hasEverPurchasedPremium = false
         self.generationService = generationService
         self.generationService = generationService
-            ?? PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+            ?? PostGenerationServiceFactory.make(usesLiveAI: Self.liveAIEnabled(
+                hasPremiumAccess: hasPremiumAccess,
+                hasEverPurchasedPremium: false,
+                freeUsageManager: freeUsageManager
+            ))
     }
     }
 
 
-    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+    func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
         self.hasPremiumAccess = hasPremiumAccess
         self.hasPremiumAccess = hasPremiumAccess
+        self.hasEverPurchasedPremium = hasEverPurchasedPremium
+        refreshFreeUsageState()
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        setSubscriptionState(
+            hasPremiumAccess: hasPremiumAccess,
+            hasEverPurchasedPremium: hasEverPurchasedPremium
+        )
+    }
+
+    func refreshFreeUsageState() {
+        remainingFreeAIUses = freeUsageManager.remainingUses
+        refreshGenerationService()
+    }
+
+    private func refreshGenerationService() {
         if injectedGenerationService == nil {
         if injectedGenerationService == nil {
-            generationService = PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+            generationService = PostGenerationServiceFactory.make(usesLiveAI: usesLiveAI)
         }
         }
     }
     }
 
 
+    private static func liveAIEnabled(
+        hasPremiumAccess: Bool,
+        hasEverPurchasedPremium: Bool,
+        freeUsageManager: AIFreeUsageManager
+    ) -> Bool {
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
     var formattedSubreddit: String {
     var formattedSubreddit: String {
         let name = PostDraftValidator.normalizedSubreddit(draft.subreddit)
         let name = PostDraftValidator.normalizedSubreddit(draft.subreddit)
         guard !name.isEmpty else { return "r/subreddit" }
         guard !name.isEmpty else { return "r/subreddit" }
@@ -46,7 +88,27 @@ final class PostGeneratorViewModel {
     }
     }
 
 
     var usesLiveAI: Bool {
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
+    var generationEngineSubtitle: String {
+        if usesLiveAI {
+            if hasPremiumAccess {
+                return "Create Reddit-ready posts with AI"
+            }
+            let remaining = remainingFreeAIUses
+            let useLabel = remaining == 1 ? "use" : "uses"
+            return "Create Reddit-ready posts with AI (\(remaining) free \(useLabel) left)"
+        }
+        if hasEverPurchasedPremium {
+            return "Upgrade to Premium for unlimited AI generations"
+        }
+        return "Create Reddit-ready posts with AI templates"
     }
     }
 
 
     var hasDraftChanges: Bool {
     var hasDraftChanges: Bool {
@@ -127,6 +189,11 @@ final class PostGeneratorViewModel {
     func generatePost() async {
     func generatePost() async {
         guard canGenerate else { return }
         guard canGenerate else { return }
 
 
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         if needsOverwriteConfirmation {
         if needsOverwriteConfirmation {
             showOverwriteConfirmation = true
             showOverwriteConfirmation = true
             return
             return
@@ -226,16 +293,27 @@ final class PostGeneratorViewModel {
     }
     }
 
 
     private func performGenerate(replaceExisting: Bool) async {
     private func performGenerate(replaceExisting: Bool) async {
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         isGenerating = true
         isGenerating = true
         errorMessage = nil
         errorMessage = nil
         successMessage = nil
         successMessage = nil
         await Task.yield()
         await Task.yield()
 
 
+        let usedLiveAI = usesLiveAI
+
         do {
         do {
             let result = try await generationService.generatePost(from: draft)
             let result = try await generationService.generatePost(from: draft)
             applyGeneratedPost(result, replaceExisting: replaceExisting)
             applyGeneratedPost(result, replaceExisting: replaceExisting)
             selectedTab = .preview
             selectedTab = .preview
-            let engine = usesLiveAI ? "AI" : "local AI templates"
+            if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
+                freeUsageManager.recordUse(for: .postGenerator)
+                refreshFreeUsageState()
+            }
+            let engine = usedLiveAI ? "AI" : "local AI templates"
             successMessage = "Post generated successfully using \(engine)."
             successMessage = "Post generated successfully using \(engine)."
             let entry = AIHistoryEntry.fromPostGenerator(draft: draft)
             let entry = AIHistoryEntry.fromPostGenerator(draft: draft)
             Task { @MainActor in
             Task { @MainActor in
@@ -268,6 +346,10 @@ final class PostGeneratorViewModel {
         }
         }
     }
     }
 
 
+    private func requiresPaywallForGeneration() -> Bool {
+        !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
+    }
+
     private func releaseImageAccess() {
     private func releaseImageAccess() {
         if isAccessingImageResource, let imageAccessURL {
         if isAccessingImageResource, let imageAccessURL {
             imageAccessURL.stopAccessingSecurityScopedResource()
             imageAccessURL.stopAccessingSecurityScopedResource()

+ 83 - 6
App AI for Reddit/ViewModels/TitleOptimizerViewModel.swift

@@ -14,31 +14,93 @@ final class TitleOptimizerViewModel {
     var selectedVariantID: UUID?
     var selectedVariantID: UUID?
 
 
     private var hasPremiumAccess = false
     private var hasPremiumAccess = false
+    private var hasEverPurchasedPremium = false
     private var optimizationService: any TitleOptimizationServiceProtocol
     private var optimizationService: any TitleOptimizationServiceProtocol
     private let injectedOptimizationService: (any TitleOptimizationServiceProtocol)?
     private let injectedOptimizationService: (any TitleOptimizationServiceProtocol)?
     private let historyManager: AIHistoryManager
     private let historyManager: AIHistoryManager
+    private let freeUsageManager: AIFreeUsageManager
+    var onPaywallRequired: (() -> Void)?
+    private(set) var remainingFreeAIUses: Int
 
 
     init(
     init(
         hasPremiumAccess: Bool = false,
         hasPremiumAccess: Bool = false,
         optimizationService: (any TitleOptimizationServiceProtocol)? = nil,
         optimizationService: (any TitleOptimizationServiceProtocol)? = nil,
-        historyManager: AIHistoryManager = .shared
+        historyManager: AIHistoryManager = .shared,
+        freeUsageManager: AIFreeUsageManager = .shared
     ) {
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedOptimizationService = optimizationService
         self.injectedOptimizationService = optimizationService
         self.historyManager = historyManager
         self.historyManager = historyManager
+        self.freeUsageManager = freeUsageManager
+        self.remainingFreeAIUses = freeUsageManager.remainingUses
+        self.hasEverPurchasedPremium = false
         self.optimizationService = optimizationService
         self.optimizationService = optimizationService
-            ?? TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+            ?? TitleOptimizationServiceFactory.make(usesLiveAI: Self.liveAIEnabled(
+                hasPremiumAccess: hasPremiumAccess,
+                hasEverPurchasedPremium: false,
+                freeUsageManager: freeUsageManager
+            ))
     }
     }
 
 
-    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+    func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
         self.hasPremiumAccess = hasPremiumAccess
         self.hasPremiumAccess = hasPremiumAccess
+        self.hasEverPurchasedPremium = hasEverPurchasedPremium
+        refreshFreeUsageState()
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        setSubscriptionState(
+            hasPremiumAccess: hasPremiumAccess,
+            hasEverPurchasedPremium: hasEverPurchasedPremium
+        )
+    }
+
+    func refreshFreeUsageState() {
+        remainingFreeAIUses = freeUsageManager.remainingUses
+        refreshOptimizationService()
+    }
+
+    private func refreshOptimizationService() {
         if injectedOptimizationService == nil {
         if injectedOptimizationService == nil {
-            optimizationService = TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+            optimizationService = TitleOptimizationServiceFactory.make(usesLiveAI: usesLiveAI)
         }
         }
     }
     }
 
 
+    private static func liveAIEnabled(
+        hasPremiumAccess: Bool,
+        hasEverPurchasedPremium: Bool,
+        freeUsageManager: AIFreeUsageManager
+    ) -> Bool {
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
     var usesLiveAI: Bool {
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
+    var generationEngineSubtitle: String {
+        if usesLiveAI {
+            if hasPremiumAccess {
+                return "Craft viral Reddit titles with AI"
+            }
+            let remaining = remainingFreeAIUses
+            let useLabel = remaining == 1 ? "use" : "uses"
+            return "Craft viral Reddit titles with AI (\(remaining) free \(useLabel) left)"
+        }
+        if hasEverPurchasedPremium {
+            return "Upgrade to Premium for unlimited AI generations"
+        }
+        return "Craft viral Reddit titles with AI templates"
     }
     }
 
 
     var formattedSubreddit: String {
     var formattedSubreddit: String {
@@ -116,18 +178,29 @@ final class TitleOptimizerViewModel {
             return
             return
         }
         }
 
 
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         isOptimizing = true
         isOptimizing = true
         errorMessage = nil
         errorMessage = nil
         successMessage = nil
         successMessage = nil
         await Task.yield()
         await Task.yield()
         defer { isOptimizing = false }
         defer { isOptimizing = false }
 
 
+        let usedLiveAI = usesLiveAI
+
         do {
         do {
             let result = try await optimizationService.optimizeTitles(from: draft)
             let result = try await optimizationService.optimizeTitles(from: draft)
             analysis = result.analysis
             analysis = result.analysis
             variants = result.variants
             variants = result.variants
             selectedVariantID = result.variants.first?.id
             selectedVariantID = result.variants.first?.id
-            let engine = usesLiveAI ? "AI" : "local AI templates"
+            if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
+                freeUsageManager.recordUse(for: .titleOptimizer)
+                refreshFreeUsageState()
+            }
+            let engine = usedLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
             successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
             let entry = AIHistoryEntry.fromTitleOptimizer(
             let entry = AIHistoryEntry.fromTitleOptimizer(
                 draft: draft,
                 draft: draft,
@@ -207,4 +280,8 @@ final class TitleOptimizerViewModel {
         errorMessage = nil
         errorMessage = nil
         successMessage = nil
         successMessage = nil
     }
     }
+
+    private func requiresPaywallForGeneration() -> Bool {
+        !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
+    }
 }
 }

+ 1 - 1
App AI for Reddit/Views/CommentWriterView.swift

@@ -74,7 +74,7 @@ struct CommentWriterView: View {
                         }
                         }
                     }
                     }
 
 
-                    Text(viewModel.usesLiveAI ? "Write Reddit-ready comments with AI" : "Write Reddit-ready comments with AI templates")
+                    Text(viewModel.generationEngineSubtitle)
                         .font(.system(size: 11))
                         .font(.system(size: 11))
                         .foregroundStyle(AppTheme.textSecondary)
                         .foregroundStyle(AppTheme.textSecondary)
                 }
                 }

+ 32 - 7
App AI for Reddit/Views/FrontPageView.swift

@@ -5,6 +5,7 @@ struct FrontPageView: View {
     @EnvironmentObject private var appRating: AppRatingManager
     @EnvironmentObject private var appRating: AppRatingManager
     @Environment(AppearanceManager.self) private var appearanceManager
     @Environment(AppearanceManager.self) private var appearanceManager
     @Bindable var session: AppSessionState
     @Bindable var session: AppSessionState
+    @Bindable private var freeUsageManager = AIFreeUsageManager.shared
 
 
     private var viewModel: FrontPageViewModel { session.frontPage }
     private var viewModel: FrontPageViewModel { session.frontPage }
     private var paywallViewModel: PaywallViewModel { session.paywall }
     private var paywallViewModel: PaywallViewModel { session.paywall }
@@ -56,20 +57,27 @@ struct FrontPageView: View {
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .onChange(of: subscriptions.hasPremiumAccess) { _, hasPremium in
         .onChange(of: subscriptions.hasPremiumAccess) { _, hasPremium in
             viewModel.dismissPaywall()
             viewModel.dismissPaywall()
-            syncToolPremiumAccess()
+            syncToolSubscriptionState()
             appRating.updatePremiumStatus(hasPremium)
             appRating.updatePremiumStatus(hasPremium)
             if subscriptions.hasResolvedPremiumStatus {
             if subscriptions.hasResolvedPremiumStatus {
                 configureRedditAccessForPremiumStatus()
                 configureRedditAccessForPremiumStatus()
             }
             }
         }
         }
+        .onChange(of: subscriptions.hasEverPurchasedPremium) { _, _ in
+            syncToolSubscriptionState()
+        }
         .onChange(of: subscriptions.hasResolvedPremiumStatus) { _, resolved in
         .onChange(of: subscriptions.hasResolvedPremiumStatus) { _, resolved in
             if resolved {
             if resolved {
-                syncToolPremiumAccess()
+                syncToolSubscriptionState()
                 configureRedditAccessForPremiumStatus()
                 configureRedditAccessForPremiumStatus()
             }
             }
         }
         }
+        .onChange(of: freeUsageManager.usedCount) { _, _ in
+            syncToolSubscriptionState()
+        }
         .onAppear {
         .onAppear {
-            syncToolPremiumAccess()
+            syncToolSubscriptionState()
+            configureToolPaywallHandlers()
             appRating.updatePremiumStatus(subscriptions.hasPremiumAccess)
             appRating.updatePremiumStatus(subscriptions.hasPremiumAccess)
             if subscriptions.hasResolvedPremiumStatus {
             if subscriptions.hasResolvedPremiumStatus {
                 configureRedditAccessForPremiumStatus()
                 configureRedditAccessForPremiumStatus()
@@ -160,11 +168,28 @@ struct FrontPageView: View {
         }
         }
     }
     }
 
 
-    private func syncToolPremiumAccess() {
+    private func syncToolSubscriptionState() {
         let hasPremium = subscriptions.hasPremiumAccess
         let hasPremium = subscriptions.hasPremiumAccess
-        postGeneratorViewModel.setPremiumAccess(hasPremium)
-        titleOptimizerViewModel.setPremiumAccess(hasPremium)
-        commentWriterViewModel.setPremiumAccess(hasPremium)
+        let hasEverPurchased = subscriptions.hasEverPurchasedPremium
+        postGeneratorViewModel.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+        titleOptimizerViewModel.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+        commentWriterViewModel.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+    }
+
+    private func configureToolPaywallHandlers() {
+        let showPaywall = { [viewModel] in viewModel.showPaywall() }
+        postGeneratorViewModel.onPaywallRequired = showPaywall
+        titleOptimizerViewModel.onPaywallRequired = showPaywall
+        commentWriterViewModel.onPaywallRequired = showPaywall
     }
     }
 
 
     private func configureRedditAccessForPremiumStatus() {
     private func configureRedditAccessForPremiumStatus() {

+ 1 - 1
App AI for Reddit/Views/PostGeneratorView.swift

@@ -91,7 +91,7 @@ struct PostGeneratorView: View {
                         }
                         }
                     }
                     }
 
 
-                    Text(viewModel.usesLiveAI ? "Create Reddit-ready posts with AI" : "Create Reddit-ready posts with AI templates")
+                    Text(viewModel.generationEngineSubtitle)
                         .font(.system(size: 11))
                         .font(.system(size: 11))
                         .foregroundStyle(AppTheme.textSecondary)
                         .foregroundStyle(AppTheme.textSecondary)
                 }
                 }

+ 1 - 1
App AI for Reddit/Views/TitleOptimizerView.swift

@@ -57,7 +57,7 @@ struct TitleOptimizerView: View {
                     Text("Title Optimizer")
                     Text("Title Optimizer")
                         .font(.system(size: 18, weight: .semibold))
                         .font(.system(size: 18, weight: .semibold))
                         .foregroundStyle(AppTheme.textPrimary)
                         .foregroundStyle(AppTheme.textPrimary)
-                    Text(viewModel.usesLiveAI ? "Craft viral Reddit titles with AI" : "Craft viral Reddit titles with AI templates")
+                    Text(viewModel.generationEngineSubtitle)
                         .font(.system(size: 11))
                         .font(.system(size: 11))
                         .foregroundStyle(AppTheme.textSecondary)
                         .foregroundStyle(AppTheme.textSecondary)
                 }
                 }