Pārlūkot izejas kodu

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 mēnesi atpakaļ
vecāks
revīzija
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()
                 }