Sfoglia il codice sorgente

Gate premium features behind paywall on action, not navigation.

Allow free browsing of all tools on launch and sidebar selection, and show the paywall only when non-Pro users run a feature.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 mese fa
parent
commit
66f040cfc9

+ 2 - 0
gramora/Managers/SubscriptionManager.swift

@@ -42,6 +42,7 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var purchasingPlan: PaywallPlan?
     @Published private(set) var hasPremiumAccess = false
+    @Published private(set) var hasResolvedPremiumStatus = false
     @Published var purchaseError: PurchaseError?
 
     var hasAllProductsLoaded: Bool {
@@ -276,6 +277,7 @@ final class SubscriptionManager: ObservableObject {
         }
 
         hasPremiumAccess = hasPremium
+        hasResolvedPremiumStatus = true
     }
 
     private func hasActiveSubscriptionStatus() async -> Bool {

+ 10 - 0
gramora/Utilities/SettingsEnvironment.swift

@@ -4,9 +4,19 @@ private struct SettingsActionKey: EnvironmentKey {
     static let defaultValue: () -> Void = {}
 }
 
+private struct RequirePremiumAccessKey: EnvironmentKey {
+    static let defaultValue: () -> Bool = { true }
+}
+
 extension EnvironmentValues {
     var onSettingsTapped: () -> Void {
         get { self[SettingsActionKey.self] }
         set { self[SettingsActionKey.self] = newValue }
     }
+
+    /// Returns `true` when the user has premium access. Otherwise presents the paywall and returns `false`.
+    var requirePremiumAccess: () -> Bool {
+        get { self[RequirePremiumAccessKey.self] }
+        set { self[RequirePremiumAccessKey.self] = newValue }
+    }
 }

+ 5 - 0
gramora/ViewModels/MainViewModel.swift

@@ -16,6 +16,11 @@ final class MainViewModel: ObservableObject {
         isShowingPaywall = false
     }
 
+    func selectDestination(_ destination: NavigationDestination) {
+        selectedDestination = destination
+        hideSettings()
+    }
+
     func showSettings() {
         isShowingSettings = true
     }

+ 15 - 4
gramora/Views/DictionaryView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct DictionaryView: View {
     @StateObject private var viewModel = DictionaryViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     var body: some View {
         GeometryReader { proxy in
@@ -38,6 +39,16 @@ struct DictionaryView: View {
         }
     }
 
+    private func lookUp() {
+        guard requirePremiumAccess() else { return }
+        viewModel.lookUp()
+    }
+
+    private func lookUpRecent(_ word: String) {
+        guard requirePremiumAccess() else { return }
+        viewModel.lookUpRecent(word)
+    }
+
     private var header: some View {
         HStack(alignment: .top) {
             VStack(alignment: .leading, spacing: 8) {
@@ -116,7 +127,7 @@ struct DictionaryView: View {
                         .font(.system(size: 14, weight: .medium))
                         .foregroundStyle(AppTheme.textMuted)
 
-                    TextField("Type a word and press Return...", text: $viewModel.query, onCommit: viewModel.lookUp)
+                    TextField("Type a word and press Return...", text: $viewModel.query, onCommit: lookUp)
                         .textFieldStyle(.plain)
                         .font(.system(size: 16))
                         .foregroundStyle(AppTheme.textPrimary)
@@ -134,7 +145,7 @@ struct DictionaryView: View {
                     title: "Look Up",
                     iconName: "book.fill",
                     isEnabled: viewModel.canLookUp,
-                    action: viewModel.lookUp
+                    action: lookUp
                 )
             }
 
@@ -219,7 +230,7 @@ struct DictionaryView: View {
 
             if let latestSearch = viewModel.recentSearches.first {
                 ToolbarButton(title: latestSearch, iconName: "clock") {
-                    viewModel.lookUpRecent(latestSearch)
+                    lookUpRecent(latestSearch)
                 }
             }
         }
@@ -335,7 +346,7 @@ struct DictionaryView: View {
             ) {
                 ForEach(words, id: \.self) { word in
                     ChipButton(title: word) {
-                        viewModel.lookUpRecent(word)
+                        lookUpRecent(word)
                     }
                 }
             }

+ 8 - 2
gramora/Views/EmailWriterView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct EmailWriterView: View {
     @StateObject private var viewModel = EmailWriterViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -32,7 +33,7 @@ struct EmailWriterView: View {
                 }
 
                 GradientButton(title: viewModel.isGenerating ? "Generating..." : "Generate Email") {
-                    viewModel.generateEmail()
+                    generateEmail()
                 }
                 .disabled(!viewModel.canGenerate)
                 .opacity(viewModel.canGenerate ? 1 : 0.55)
@@ -60,6 +61,11 @@ struct EmailWriterView: View {
         onRestoreHandled()
     }
 
+    private func generateEmail() {
+        guard requirePremiumAccess() else { return }
+        viewModel.generateEmail()
+    }
+
     private var header: some View {
         HStack(alignment: .top) {
             VStack(alignment: .leading, spacing: 6) {
@@ -179,7 +185,7 @@ struct EmailWriterView: View {
                     placeholder: "Describe the email you need — key points, recipient context, or a rough draft...",
                     maxWords: EmailWriterViewModel.maxWords,
                     onWordLimitReached: viewModel.notifyWordLimitReached,
-                    onSubmit: viewModel.generateEmail
+                    onSubmit: generateEmail
                 )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)

+ 2 - 0
gramora/Views/GrammarCheckerView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct GrammarCheckerView: View {
     @StateObject private var viewModel = GrammarCheckerViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -53,6 +54,7 @@ struct GrammarCheckerView: View {
                         }
 
                         GradientButton(title: viewModel.isChecking ? "Checking..." : "Check Grammar") {
+                            guard requirePremiumAccess() else { return }
                             viewModel.checkGrammar()
                         }
                         .disabled(!viewModel.canCheck)

+ 2 - 0
gramora/Views/JournalFinderView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct JournalFinderView: View {
     @StateObject private var viewModel = JournalFinderViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -99,6 +100,7 @@ struct JournalFinderView: View {
             GradientButton(
                 title: viewModel.isSearching ? "Finding Journals..." : "Find Journals"
             ) {
+                guard requirePremiumAccess() else { return }
                 viewModel.findJournals()
             }
             .disabled(!viewModel.canSearch)

+ 8 - 2
gramora/Views/LanguageTranslatorView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct LanguageTranslatorView: View {
     @StateObject private var viewModel = LanguageTranslatorViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -49,6 +50,11 @@ struct LanguageTranslatorView: View {
         onRestoreHandled()
     }
 
+    private func translate() {
+        guard requirePremiumAccess() else { return }
+        viewModel.translate()
+    }
+
     private var header: some View {
         HStack(alignment: .top) {
             VStack(alignment: .leading, spacing: 8) {
@@ -131,7 +137,7 @@ struct LanguageTranslatorView: View {
                 }
 
                 ToolbarButton(title: "Translate", iconName: "sparkles") {
-                    viewModel.translate()
+                    translate()
                 }
                 .disabled(!viewModel.canTranslate)
                 .opacity(viewModel.canTranslate ? 1 : 0.55)
@@ -149,7 +155,7 @@ struct LanguageTranslatorView: View {
                     placeholder: "Start writing...",
                     maxWords: LanguageTranslatorViewModel.maxWords,
                     onWordLimitReached: viewModel.notifyWordLimitReached,
-                    onSubmit: viewModel.translate
+                    onSubmit: translate
                 )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)

+ 49 - 46
gramora/Views/MainView.swift

@@ -6,56 +6,59 @@ struct MainView: View {
     @EnvironmentObject private var themeManager: ThemeManager
 
     var body: some View {
-        Group {
-            if viewModel.isShowingPaywall {
-                PaywallView(onClose: viewModel.hidePaywall)
-                    .transition(.opacity)
-            } else {
-                HStack(spacing: 0) {
-                    SidebarView(
-                        selectedDestination: Binding(
-                            get: { viewModel.selectedDestination },
-                            set: { destination in
-                                viewModel.selectedDestination = destination
-                                viewModel.hideSettings()
-                            }
-                        ),
-                        isShowingSettings: viewModel.isShowingSettings,
-                        isPremium: subscriptions.hasPremiumAccess,
-                        onUpgradeTapped: viewModel.showPaywall,
-                        onManageSubscriptionTapped: viewModel.showPaywall
-                    )
-                    .frame(maxHeight: .infinity)
+        HStack(spacing: 0) {
+            SidebarView(
+                selectedDestination: Binding(
+                    get: { viewModel.selectedDestination },
+                    set: { destination in
+                        viewModel.selectDestination(destination)
+                    }
+                ),
+                isShowingSettings: viewModel.isShowingSettings,
+                isPremium: subscriptions.hasPremiumAccess,
+                onUpgradeTapped: viewModel.showPaywall,
+                onManageSubscriptionTapped: subscriptions.openSubscriptionManagement
+            )
+            .frame(maxHeight: .infinity)
 
-                    Rectangle()
-                        .fill(AppTheme.border)
-                        .frame(width: 1)
-                        .frame(maxHeight: .infinity)
-                        .ignoresSafeArea(edges: .top)
+            Rectangle()
+                .fill(AppTheme.border)
+                .frame(width: 1)
+                .frame(maxHeight: .infinity)
+                .ignoresSafeArea(edges: .top)
 
-                    Group {
-                        if viewModel.isShowingSettings {
-                            SettingsView(
-                                onClose: viewModel.hideSettings,
-                                themeManager: themeManager
-                            )
-                            .transition(.opacity)
-                        } else {
-                            contentView
-                                .transition(.opacity)
-                        }
-                    }
-                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
-                    .frame(maxWidth: .infinity, maxHeight: .infinity)
-                    .background(AppTheme.background)
-                    .ignoresSafeArea(edges: .top)
-                    .environment(\.onSettingsTapped, viewModel.showSettings)
-                }
-                .background {
-                    AppTheme.background
-                        .ignoresSafeArea(edges: .top)
+            Group {
+                if viewModel.isShowingSettings {
+                    SettingsView(
+                        onClose: viewModel.hideSettings,
+                        themeManager: themeManager
+                    )
+                    .transition(.opacity)
+                } else {
+                    contentView
+                        .transition(.opacity)
                 }
             }
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+            .frame(maxWidth: .infinity, maxHeight: .infinity)
+            .background(AppTheme.background)
+            .ignoresSafeArea(edges: .top)
+            .environment(\.onSettingsTapped, viewModel.showSettings)
+            .environment(\.requirePremiumAccess, {
+                if subscriptions.hasPremiumAccess { return true }
+                viewModel.showPaywall()
+                return false
+            })
+        }
+        .background {
+            AppTheme.background
+                .ignoresSafeArea(edges: .top)
+        }
+        .overlay {
+            if viewModel.isShowingPaywall {
+                PaywallView(onClose: viewModel.hidePaywall)
+                    .transition(.opacity)
+            }
         }
         .id(themeManager.isDarkMode)
         .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingPaywall)

+ 2 - 0
gramora/Views/ParaphrasingView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct ParaphrasingView: View {
     @StateObject private var viewModel = ParaphrasingViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -53,6 +54,7 @@ struct ParaphrasingView: View {
                         }
 
                         GradientButton(title: viewModel.isParaphrasing ? "Paraphrasing..." : "Paraphrase") {
+                            guard requirePremiumAccess() else { return }
                             viewModel.paraphrase()
                         }
                         .disabled(!viewModel.canParaphrase)

+ 2 - 0
gramora/Views/PunctuationCheckerView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct PunctuationCheckerView: View {
     @StateObject private var viewModel = PunctuationCheckerViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -53,6 +54,7 @@ struct PunctuationCheckerView: View {
                         }
 
                         GradientButton(title: viewModel.isChecking ? "Checking..." : "Check Punctuation") {
+                            guard requirePremiumAccess() else { return }
                             viewModel.checkPunctuation()
                         }
                         .disabled(!viewModel.canCheck)

+ 2 - 0
gramora/Views/SpellCheckerView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct SpellCheckerView: View {
     @StateObject private var viewModel = SpellCheckerViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -53,6 +54,7 @@ struct SpellCheckerView: View {
                         }
 
                         GradientButton(title: viewModel.isChecking ? "Checking..." : "Check Spelling") {
+                            guard requirePremiumAccess() else { return }
                             viewModel.checkSpelling()
                         }
                         .disabled(!viewModel.canCheck)

+ 2 - 0
gramora/Views/SummarizerView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct SummarizerView: View {
     @StateObject private var viewModel = SummarizerViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -53,6 +54,7 @@ struct SummarizerView: View {
                         }
 
                         GradientButton(title: viewModel.isSummarizing ? "Summarizing..." : "Summarize") {
+                            guard requirePremiumAccess() else { return }
                             viewModel.summarize()
                         }
                         .disabled(!viewModel.canSummarize)

+ 21 - 1
gramora/Views/WordsCountView.swift

@@ -3,6 +3,9 @@ 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
 
     var body: some View {
         GeometryReader { proxy in
@@ -30,7 +33,22 @@ struct WordsCountView: View {
             .padding(.bottom, dynamicBottomPadding)
         }
         .clearHostingBackground()
-        .onChange(of: viewModel.text) { _ in
+        .onChange(of: viewModel.text) { newValue in
+            let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty {
+                didPromptWordCountPaywall = false
+                viewModel.handleTextChange()
+                return
+            }
+
+            guard subscriptions.hasPremiumAccess else {
+                if !didPromptWordCountPaywall {
+                    didPromptWordCountPaywall = true
+                    requirePremiumAccess()
+                }
+                return
+            }
+
             viewModel.handleTextChange()
         }
     }
@@ -103,10 +121,12 @@ 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()
                 }