ソースを参照

Add a shared free AI tier so users can try core features before subscribing.

Offer four free AI uses across all tools, keep word count fully free, and align the terms with the new access model for App Store review.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hussain Afzal 4 週間 前
コミット
592cbc4120

+ 42 - 4
gramora/Managers/SubscriptionManager.swift

@@ -34,6 +34,9 @@ enum PurchaseError: Equatable {
 
 @MainActor
 final class SubscriptionManager: ObservableObject {
+    static let maxFreeAIUses = 4
+    private static let freeAIUsesConsumedKey = "freeAIUsesConsumed"
+
     private enum ProductLoadError: Error {
         case timeout
     }
@@ -49,6 +52,7 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var activePlan: PaywallPlan?
     @Published private(set) var activeRecurringPlan: PaywallPlan?
     @Published private(set) var introOfferEligibleByPlan: [PaywallPlan: Bool] = [:]
+    @Published private(set) var freeAIUsesConsumed = 0
     @Published var purchaseError: PurchaseError?
     @Published var subscriptionSuccessMessage: String?
     @Published var showsCancelSubscriptionReminder = false
@@ -58,17 +62,51 @@ final class SubscriptionManager: ObservableObject {
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
     }
 
+    var remainingFreeAIUses: Int {
+        max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
+    }
+
+    var canUseAIFeatures: Bool {
+        hasPremiumAccess || remainingFreeAIUses > 0
+    }
+
     private var inFlightProductLoad: Task<Void, Never>?
     private var cancellables = Set<AnyCancellable>()
+    private let defaults: UserDefaults
+
+    init(defaults: UserDefaults = .standard) {
+        self.defaults = defaults
+        hasPremiumAccess = defaults.bool(forKey: Self.cachedPremiumAccessKey)
+        freeAIUsesConsumed = min(
+            defaults.integer(forKey: Self.freeAIUsesConsumedKey),
+            Self.maxFreeAIUses
+        )
+
+        observeAIFeatureUsage()
 
-    init() {
-        hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumAccessKey)
         Task { await listenForTransactionUpdates() }
         Task { await refreshPremiumAccess() }
         Task { await loadProducts(presentLoadingUI: false) }
         observeAppLifecycle()
     }
 
+    func recordSuccessfulAIUse() {
+        guard !hasPremiumAccess else { return }
+        guard freeAIUsesConsumed < Self.maxFreeAIUses else { return }
+
+        freeAIUsesConsumed += 1
+        defaults.set(freeAIUsesConsumed, forKey: Self.freeAIUsesConsumedKey)
+    }
+
+    private func observeAIFeatureUsage() {
+        NotificationCenter.default.publisher(for: .aiFeatureDidSucceed)
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] _ in
+                self?.recordSuccessfulAIUse()
+            }
+            .store(in: &cancellables)
+    }
+
     func loadProducts(presentLoadingUI: Bool? = nil) async {
         if let existing = inFlightProductLoad {
             await existing.value
@@ -413,7 +451,7 @@ final class SubscriptionManager: ObservableObject {
         activePlan = resolvedPlan
         activeRecurringPlan = resolvedRecurringPlan
         hasResolvedPremiumStatus = true
-        UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumAccessKey)
+        defaults.set(hasPremium, forKey: Self.cachedPremiumAccessKey)
     }
 
     private func applyOptimisticPremiumAccess(for transaction: Transaction) {
@@ -427,7 +465,7 @@ final class SubscriptionManager: ObservableObject {
             hasManageableSubscription = true
             activeRecurringPlan = plan
         }
-        UserDefaults.standard.set(true, forKey: Self.cachedPremiumAccessKey)
+        defaults.set(true, forKey: Self.cachedPremiumAccessKey)
     }
 
     private func refreshIntroOfferEligibility() async {

+ 11 - 0
gramora/Utilities/AIFeatureUsage.swift

@@ -0,0 +1,11 @@
+import Foundation
+
+extension Notification.Name {
+    static let aiFeatureDidSucceed = Notification.Name("aiFeatureDidSucceed")
+}
+
+enum AIFeatureUsage {
+    static func recordSuccessfulUse() {
+        NotificationCenter.default.post(name: .aiFeatureDidSucceed, object: nil)
+    }
+}

+ 1 - 1
gramora/Utilities/SettingsEnvironment.swift

@@ -14,7 +14,7 @@ extension EnvironmentValues {
         set { self[SettingsActionKey.self] = newValue }
     }
 
-    /// Returns `true` when the user has premium access. Otherwise presents the paywall and returns `false`.
+    /// Returns `true` when the user can run an AI feature (premium or free uses remaining). Otherwise presents the paywall and returns `false`.
     var requirePremiumAccess: () -> Bool {
         get { self[RequirePremiumAccessKey.self] }
         set { self[RequirePremiumAccessKey.self] = newValue }

+ 1 - 0
gramora/ViewModels/DictionaryViewModel.swift

@@ -132,6 +132,7 @@ final class DictionaryViewModel: ObservableObject {
             entry = result
             query = result.word
             addRecentSearch(result.word)
+            AIFeatureUsage.recordSuccessfulUse()
         } catch {
             guard !Task.isCancelled, activeLookupID == lookupID else { return }
             errorMessage = UserFacingError.message(for: error)

+ 1 - 0
gramora/ViewModels/EmailWriterViewModel.swift

@@ -102,6 +102,7 @@ final class EmailWriterViewModel: ObservableObject {
                     emailType: requestEmailType,
                     tone: requestTone
                 )
+                AIFeatureUsage.recordSuccessfulUse()
             } catch {
                 errorMessage = EmailWriterErrors.message(for: error)
             }

+ 1 - 0
gramora/ViewModels/GrammarCheckerViewModel.swift

@@ -104,6 +104,7 @@ final class GrammarCheckerViewModel: ObservableObject {
                 correctedText = result.correctedText
                 hasResults = true
                 saveToHistory(text: content, issues: result.issues, correctedText: result.correctedText)
+                AIFeatureUsage.recordSuccessfulUse()
             } catch {
                 errorMessage = UserFacingError.message(for: error)
             }

+ 1 - 0
gramora/ViewModels/JournalFinderViewModel.swift

@@ -114,6 +114,7 @@ final class JournalFinderViewModel: ObservableObject {
                 result = searchResult
                 hasResults = true
                 saveToHistory(criteria: searchCriteria, result: searchResult)
+                AIFeatureUsage.recordSuccessfulUse()
             } catch {
                 errorMessage = UserFacingError.message(for: error)
             }

+ 1 - 0
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -118,6 +118,7 @@ final class LanguageTranslatorViewModel: ObservableObject {
                     sourceLanguage: requestSourceLanguage,
                     targetLanguage: requestTargetLanguage
                 )
+                AIFeatureUsage.recordSuccessfulUse()
             } catch {
                 errorMessage = UserFacingError.message(for: error)
             }

+ 1 - 0
gramora/ViewModels/ParaphrasingViewModel.swift

@@ -95,6 +95,7 @@ final class ParaphrasingViewModel: ObservableObject {
                 let result = try await paraphrasingService.paraphrase(content)
                 paraphrasedText = result
                 saveToHistory(text: content, paraphrasedText: result)
+                AIFeatureUsage.recordSuccessfulUse()
             } catch {
                 errorMessage = UserFacingError.message(for: error)
             }

+ 1 - 0
gramora/ViewModels/PunctuationCheckerViewModel.swift

@@ -99,6 +99,7 @@ final class PunctuationCheckerViewModel: ObservableObject {
                 issues = result.issues
                 hasResults = true
                 saveToHistory(text: content, issues: result.issues, correctedText: result.correctedText)
+                AIFeatureUsage.recordSuccessfulUse()
             } catch {
                 errorMessage = UserFacingError.message(for: error)
             }

+ 1 - 0
gramora/ViewModels/SpellCheckerViewModel.swift

@@ -104,6 +104,7 @@ final class SpellCheckerViewModel: ObservableObject {
                 correctedText = result.correctedText
                 hasResults = true
                 saveToHistory(text: content, mistakes: result.mistakes, correctedText: result.correctedText)
+                AIFeatureUsage.recordSuccessfulUse()
             } catch {
                 errorMessage = UserFacingError.message(for: error)
             }

+ 1 - 0
gramora/ViewModels/SummarizerViewModel.swift

@@ -95,6 +95,7 @@ final class SummarizerViewModel: ObservableObject {
                 let result = try await summarizerService.summarize(content)
                 summary = result
                 saveToHistory(text: content, summary: result)
+                AIFeatureUsage.recordSuccessfulUse()
             } catch {
                 errorMessage = UserFacingError.message(for: error)
             }

+ 13 - 5
gramora/Views/Components/PremiumCardView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct PremiumCardView: View {
     let isPremium: Bool
     let hasManageableSubscription: Bool
+    let remainingFreeAIUses: Int
     let onUpgradeTapped: () -> Void
     let onManageSubscriptionTapped: () -> Void
     @State private var isUpgradeHovered = false
@@ -12,6 +13,17 @@ struct PremiumCardView: View {
         return hasManageableSubscription ? "Manage Subscription" : "View Premium"
     }
 
+    private var subtitle: String {
+        if isPremium {
+            return "You have unlimited checks, advanced features and an ad-free experience."
+        }
+        if remainingFreeAIUses > 0 {
+            let useLabel = remainingFreeAIUses == 1 ? "use" : "uses"
+            return "\(remainingFreeAIUses) free AI \(useLabel) remaining across all tools. Upgrade for unlimited access."
+        }
+        return "Free AI uses used. Upgrade for unlimited checks and advanced features."
+    }
+
     var body: some View {
         VStack(alignment: .leading, spacing: 10) {
             HStack(spacing: 8) {
@@ -24,11 +36,7 @@ struct PremiumCardView: View {
                     .foregroundStyle(AppTheme.textPrimary)
             }
 
-            Text(
-                isPremium
-                    ? "You have unlimited checks, advanced features and an ad-free experience."
-                    : "Get unlimited checks, advanced features and an ad-free experience."
-            )
+            Text(subtitle)
                 .font(.system(size: 11))
                 .foregroundStyle(AppTheme.textSecondary)
                 .lineSpacing(2)

+ 2 - 4
gramora/Views/MainView.swift

@@ -18,6 +18,7 @@ struct MainView: View {
                 isShowingSettings: viewModel.isShowingSettings,
                 isPremium: subscriptions.hasPremiumAccess,
                 hasManageableSubscription: subscriptions.hasManageableSubscription,
+                remainingFreeAIUses: subscriptions.remainingFreeAIUses,
                 onUpgradeTapped: viewModel.showPaywall,
                 onManageSubscriptionTapped: viewModel.showPaywall
             )
@@ -56,10 +57,7 @@ struct MainView: View {
             }
             .environment(\.onSettingsTapped, viewModel.showSettings)
             .environment(\.requirePremiumAccess, {
-                if !subscriptions.hasResolvedPremiumStatus {
-                    return subscriptions.hasPremiumAccess
-                }
-                if subscriptions.hasPremiumAccess { return true }
+                if subscriptions.canUseAIFeatures { return true }
                 viewModel.showPaywall()
                 return false
             })

+ 2 - 0
gramora/Views/SidebarView.swift

@@ -5,6 +5,7 @@ struct SidebarView: View {
     let isShowingSettings: Bool
     let isPremium: Bool
     let hasManageableSubscription: Bool
+    let remainingFreeAIUses: Int
     let onUpgradeTapped: () -> Void
     let onManageSubscriptionTapped: () -> Void
 
@@ -32,6 +33,7 @@ struct SidebarView: View {
             PremiumCardView(
                 isPremium: isPremium,
                 hasManageableSubscription: hasManageableSubscription,
+                remainingFreeAIUses: remainingFreeAIUses,
                 onUpgradeTapped: onUpgradeTapped,
                 onManageSubscriptionTapped: onManageSubscriptionTapped
             )

+ 23 - 73
gramora/Views/WordsCountView.swift

@@ -3,13 +3,6 @@ import SwiftUI
 struct WordsCountView: View {
     @StateObject private var viewModel = WordsCountViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
-    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
-    @EnvironmentObject private var subscriptions: SubscriptionManager
-    @State private var didPromptWordCountPaywall = false
-
-    private var hasPremium: Bool {
-        subscriptions.hasPremiumAccess
-    }
 
     var body: some View {
         GeometryReader { proxy in
@@ -37,10 +30,8 @@ struct WordsCountView: View {
             .padding(.bottom, dynamicBottomPadding)
         }
         .clearHostingBackground()
-        .onChange(of: subscriptions.hasPremiumAccess) { isPremium in
-            if isPremium {
-                viewModel.handleTextChange()
-            }
+        .onChange(of: viewModel.text) { _ in
+            viewModel.handleTextChange()
         }
     }
 
@@ -76,43 +67,31 @@ struct WordsCountView: View {
         VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
             YourContentHeader()
 
-            Text(hasPremium ? viewModel.wordCountLabel : "Words Count")
+            Text(viewModel.wordCountLabel)
                 .font(.system(size: 18, weight: .semibold))
                 .foregroundStyle(AppTheme.textPrimary)
 
             statsRow
 
             VStack(alignment: .leading, spacing: 6) {
-                ZStack(alignment: .topLeading) {
-                    ThinCaretTextEditor(
-                        text: premiumGatedText,
-                        placeholder: "Type or paste your text here to count words...",
-                        maxWords: WordsCountViewModel.maxWords,
-                        onWordLimitReached: viewModel.notifyWordLimitReached
-                    )
-                        .font(.system(size: 14))
-                        .foregroundStyle(AppTheme.textPrimary)
-                        .frame(maxWidth: .infinity, maxHeight: .infinity)
-                        .frame(minHeight: 96)
-                        .background(AppTheme.surface)
-                        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
-                        .overlay(
-                            RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
-                                .stroke(AppTheme.inputBorder, lineWidth: 1)
-                        )
-
-                    if !hasPremium {
+                ThinCaretTextEditor(
+                    text: $viewModel.text,
+                    placeholder: "Type or paste your text here to count words...",
+                    maxWords: WordsCountViewModel.maxWords,
+                    onWordLimitReached: viewModel.notifyWordLimitReached
+                )
+                    .font(.system(size: 14))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .frame(minHeight: 96)
+                    .background(AppTheme.surface)
+                    .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                    .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
-                            .fill(Color.clear)
-                            .contentShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
-                            .onTapGesture {
-                                didPromptWordCountPaywall = false
-                                _ = requirePremiumAccess()
-                            }
-                    }
-                }
+                            .stroke(AppTheme.inputBorder, lineWidth: 1)
+                    )
 
-                Text(hasPremium ? viewModel.wordCountLimitLabel : "Upgrade to count words")
+                Text(viewModel.wordCountLimitLabel)
                     .font(.system(size: 11))
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
@@ -121,12 +100,10 @@ struct WordsCountView: View {
 
             HStack(spacing: 8) {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
-                    guard requirePremiumAccess() else { return }
                     viewModel.pasteText()
                 }
 
                 ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
-                    guard requirePremiumAccess() else { return }
                     viewModel.uploadFile()
                 }
 
@@ -134,7 +111,6 @@ struct WordsCountView: View {
 
                 ClearButton {
                     viewModel.clearText()
-                    didPromptWordCountPaywall = false
                 }
             }
         }
@@ -149,38 +125,12 @@ struct WordsCountView: View {
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
 
-    private var premiumGatedText: Binding<String> {
-        Binding(
-            get: { viewModel.text },
-            set: { newValue in
-                let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
-                if trimmed.isEmpty {
-                    didPromptWordCountPaywall = false
-                    viewModel.text = newValue
-                    viewModel.handleTextChange()
-                    return
-                }
-
-                guard subscriptions.hasPremiumAccess else {
-                    if !didPromptWordCountPaywall {
-                        didPromptWordCountPaywall = true
-                        requirePremiumAccess()
-                    }
-                    return
-                }
-
-                viewModel.text = newValue
-                viewModel.handleTextChange()
-            }
-        )
-    }
-
     private var statsRow: some View {
         HStack(spacing: 12) {
-            StatItem(title: "Words", value: hasPremium ? "\(viewModel.wordCount)" : "—")
-            StatItem(title: "Sentences", value: hasPremium ? "\(viewModel.sentenceCount)" : "—")
-            StatItem(title: "Paragraphs", value: hasPremium ? "\(viewModel.paragraphCount)" : "—")
-            StatItem(title: "Characters", value: hasPremium ? "\(viewModel.characterCount)" : "—")
+            StatItem(title: "Words", value: "\(viewModel.wordCount)")
+            StatItem(title: "Sentences", value: "\(viewModel.sentenceCount)")
+            StatItem(title: "Paragraphs", value: "\(viewModel.paragraphCount)")
+            StatItem(title: "Characters", value: "\(viewModel.characterCount)")
             Spacer(minLength: 0)
         }
         .padding(.vertical, 6)

+ 5 - 2
website/terms/index.html

@@ -76,8 +76,11 @@
 
         <h3>3.1 Free and Premium Access</h3>
         <p>
-          Certain features of Grammar Pro are available for free. Premium features require an active
-          subscription or one-time purchase. Feature availability may change at our discretion.
+          Certain features of Grammar Pro are available for free, including unlimited word
+          counting. Free users may use AI-powered tools up to four (4) times in total across
+          all tools (for example, grammar checking, spell checking, or paraphrasing). Premium
+          features require an active subscription or one-time purchase. Feature availability may
+          change at our discretion.
         </p>
 
         <h3>3.2 Billing</h3>