Procházet zdrojové kódy

Add full-page Settings screen accessible from tool header gear icons.

Introduces data and about settings with history and dictionary cleanup, replacing the placeholder gear buttons across all tools.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 před 1 měsícem
rodič
revize
f12f406711

+ 12 - 0
gramora/Utilities/SettingsEnvironment.swift

@@ -0,0 +1,12 @@
+import SwiftUI
+
+private struct SettingsActionKey: EnvironmentKey {
+    static let defaultValue: () -> Void = {}
+}
+
+extension EnvironmentValues {
+    var onSettingsTapped: () -> Void {
+        get { self[SettingsActionKey.self] }
+        set { self[SettingsActionKey.self] = newValue }
+    }
+}

+ 9 - 0
gramora/ViewModels/MainViewModel.swift

@@ -5,6 +5,7 @@ import Foundation
 final class MainViewModel: ObservableObject {
     @Published var selectedDestination: NavigationDestination = .grammarChecker
     @Published var isShowingPaywall = false
+    @Published var isShowingSettings = false
     @Published var pendingHistoryEntry: HistoryEntry?
 
     func showPaywall() {
@@ -15,6 +16,14 @@ final class MainViewModel: ObservableObject {
         isShowingPaywall = false
     }
 
+    func showSettings() {
+        isShowingSettings = true
+    }
+
+    func hideSettings() {
+        isShowingSettings = false
+    }
+
     func openHistoryEntry(_ entry: HistoryEntry) {
         pendingHistoryEntry = entry
         selectedDestination = entry.tool.navigationDestination

+ 55 - 0
gramora/ViewModels/SettingsViewModel.swift

@@ -0,0 +1,55 @@
+import Combine
+import Foundation
+
+@MainActor
+final class SettingsViewModel: ObservableObject {
+    @Published private(set) var historyEntryCount = 0
+    @Published private(set) var dictionarySearchCount = 0
+    @Published var isShowingClearHistoryConfirmation = false
+    @Published var isShowingClearSearchesConfirmation = false
+
+    private let historyStorage: HistoryStoring
+    private let defaults: UserDefaults
+    private var cancellables = Set<AnyCancellable>()
+
+    private static let dictionaryRecentSearchesKey = "dictionaryRecentSearches"
+
+    init(
+        historyStorage: HistoryStoring? = nil,
+        defaults: UserDefaults = .standard
+    ) {
+        self.historyStorage = historyStorage ?? HistoryStorageService.shared
+        self.defaults = defaults
+        refreshDataCounts()
+
+        NotificationCenter.default.publisher(for: .historyDidChange)
+            .receive(on: RunLoop.main)
+            .sink { [weak self] _ in
+                self?.refreshDataCounts()
+            }
+            .store(in: &cancellables)
+    }
+
+    var appVersion: String {
+        let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
+        let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
+        return "\(version) (\(build))"
+    }
+
+    func refreshDataCounts() {
+        historyEntryCount = historyStorage.loadEntries().count
+        dictionarySearchCount = defaults.stringArray(forKey: Self.dictionaryRecentSearchesKey)?.count ?? 0
+    }
+
+    func clearHistory() {
+        historyStorage.clearAll()
+        refreshDataCounts()
+        isShowingClearHistoryConfirmation = false
+    }
+
+    func clearDictionarySearches() {
+        defaults.removeObject(forKey: Self.dictionaryRecentSearchesKey)
+        refreshDataCounts()
+        isShowingClearSearchesConfirmation = false
+    }
+}

+ 2 - 3
gramora/Views/DictionaryView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct DictionaryView: View {
     @StateObject private var viewModel = DictionaryViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
 
@@ -74,9 +75,7 @@ struct DictionaryView: View {
 
             Spacer()
 
-            IconButton {
-                SettingsGearIcon()
-            } action: {}
+            IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
             .accessibilityLabel("Settings")
         }
     }

+ 2 - 3
gramora/Views/EmailWriterView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct EmailWriterView: View {
     @StateObject private var viewModel = EmailWriterViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -84,9 +85,7 @@ struct EmailWriterView: View {
 
             Spacer()
 
-            IconButton {
-                SettingsGearIcon()
-            } action: {}
+            IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
             .accessibilityLabel("Settings")
         }
     }

+ 2 - 3
gramora/Views/GrammarCheckerView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct GrammarCheckerView: View {
     @StateObject private var viewModel = GrammarCheckerViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -140,9 +141,7 @@ struct GrammarCheckerView: View {
 
                 Spacer()
 
-                IconButton {
-                    SettingsGearIcon()
-                } action: {}
+                IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
                 .accessibilityLabel("Settings")
             }
         }

+ 2 - 3
gramora/Views/JournalFinderView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct JournalFinderView: View {
     @StateObject private var viewModel = JournalFinderViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -192,9 +193,7 @@ struct JournalFinderView: View {
 
             Spacer()
 
-            IconButton {
-                SettingsGearIcon()
-            } action: {}
+            IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
             .accessibilityLabel("Settings")
         }
     }

+ 2 - 3
gramora/Views/LanguageTranslatorView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct LanguageTranslatorView: View {
     @StateObject private var viewModel = LanguageTranslatorViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -73,9 +74,7 @@ struct LanguageTranslatorView: View {
 
             Spacer()
 
-            IconButton {
-                SettingsGearIcon()
-            } action: {}
+            IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
             .accessibilityLabel("Settings")
         }
     }

+ 12 - 2
gramora/Views/MainView.swift

@@ -28,11 +28,20 @@ struct MainView: View {
                     ZStack {
                         BackgroundDecorations()
 
-                        contentView
-                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                        Group {
+                            if viewModel.isShowingSettings {
+                                SettingsView(onClose: viewModel.hideSettings)
+                                .transition(.opacity)
+                            } else {
+                                contentView
+                                    .transition(.opacity)
+                            }
+                        }
+                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                     }
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
                     .ignoresSafeArea(edges: .top)
+                    .environment(\.onSettingsTapped, viewModel.showSettings)
                 }
                 .background {
                     AppTheme.background
@@ -41,6 +50,7 @@ struct MainView: View {
             }
         }
         .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingPaywall)
+        .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingSettings)
         .onChange(of: subscriptions.hasPremiumAccess) { hasPremium in
             if hasPremium {
                 viewModel.hidePaywall()

+ 2 - 3
gramora/Views/ParaphrasingView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct ParaphrasingView: View {
     @StateObject private var viewModel = ParaphrasingViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     var body: some View {
         GeometryReader { proxy in
@@ -120,9 +121,7 @@ struct ParaphrasingView: View {
 
                 Spacer()
 
-                IconButton {
-                    SettingsGearIcon()
-                } action: {}
+                IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
                 .accessibilityLabel("Settings")
             }
         }

+ 2 - 3
gramora/Views/PunctuationCheckerView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct PunctuationCheckerView: View {
     @StateObject private var viewModel = PunctuationCheckerViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     var body: some View {
         GeometryReader { proxy in
@@ -120,9 +121,7 @@ struct PunctuationCheckerView: View {
 
                 Spacer()
 
-                IconButton {
-                    SettingsGearIcon()
-                } action: {}
+                IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
                 .accessibilityLabel("Settings")
             }
         }

+ 335 - 0
gramora/Views/SettingsView.swift

@@ -0,0 +1,335 @@
+import SwiftUI
+
+struct SettingsView: View {
+    @StateObject private var viewModel = SettingsViewModel()
+
+    let onClose: () -> Void
+
+    var body: some View {
+        GeometryReader { proxy in
+            let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
+            let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
+
+            VStack(alignment: .leading, spacing: 0) {
+                header
+                    .padding(.bottom, 24)
+
+                settingsCard
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+            }
+            .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+            .padding(.horizontal, dynamicHorizontalPadding)
+            .padding(.top, AppTheme.brandLabelTopInset)
+            .padding(.bottom, dynamicBottomPadding)
+        }
+        .confirmationDialog(
+            "Clear all history?",
+            isPresented: $viewModel.isShowingClearHistoryConfirmation,
+            titleVisibility: .visible
+        ) {
+            Button("Clear All", role: .destructive) {
+                viewModel.clearHistory()
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("This will permanently remove all saved history items.")
+        }
+        .confirmationDialog(
+            "Clear dictionary searches?",
+            isPresented: $viewModel.isShowingClearSearchesConfirmation,
+            titleVisibility: .visible
+        ) {
+            Button("Clear", role: .destructive) {
+                viewModel.clearDictionarySearches()
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("This will remove your recent dictionary lookups.")
+        }
+    }
+
+    private var header: some View {
+        VStack(alignment: .leading, spacing: 16) {
+            IconButton(iconName: "arrow.left", action: onClose)
+                .accessibilityLabel("Go back")
+
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Settings")
+                    .font(.system(size: 30, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Text("Manage your account, data, and app preferences.")
+                    .font(.system(size: 15))
+                    .foregroundStyle(AppTheme.teal)
+            }
+        }
+    }
+
+    private var settingsCard: some View {
+        ScrollView(.vertical, showsIndicators: false) {
+            VStack(alignment: .leading, spacing: 24) {
+                dataSection
+                aboutSection
+            }
+            .padding(AppTheme.contentCardPadding)
+            .padding(.top, AppTheme.contentCardTopPadding)
+            .padding(.leading, AppTheme.contentCardLeadingPadding)
+            .padding(.bottom, AppTheme.contentCardBottomPadding)
+        }
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.inputBorder, lineWidth: 1)
+        )
+    }
+
+    private var dataSection: some View {
+        SettingsSectionCard(title: "Data & Privacy", iconName: "externaldrive.fill") {
+            VStack(spacing: 0) {
+                SettingsActionRow(
+                    iconName: "clock.arrow.circlepath",
+                    title: "History",
+                    subtitle: historySubtitle,
+                    actionTitle: "Clear",
+                    isDestructive: true,
+                    isActionEnabled: viewModel.historyEntryCount > 0
+                ) {
+                    viewModel.isShowingClearHistoryConfirmation = true
+                }
+
+                SettingsRowDivider()
+
+                SettingsActionRow(
+                    iconName: "book.closed.fill",
+                    title: "Dictionary Searches",
+                    subtitle: dictionarySubtitle,
+                    actionTitle: "Clear",
+                    isDestructive: true,
+                    isActionEnabled: viewModel.dictionarySearchCount > 0
+                ) {
+                    viewModel.isShowingClearSearchesConfirmation = true
+                }
+            }
+        }
+    }
+
+    private var aboutSection: some View {
+        SettingsSectionCard(title: "About", iconName: "info.circle.fill") {
+            VStack(spacing: 0) {
+                SettingsInfoRow(
+                    iconName: "app.badge.fill",
+                    title: "Version",
+                    value: viewModel.appVersion
+                )
+
+                SettingsRowDivider()
+
+                SettingsLinkRow(iconName: "hand.raised.fill", title: "Privacy Policy")
+                SettingsRowDivider()
+                SettingsLinkRow(iconName: "doc.text.fill", title: "Terms of Service")
+            }
+        }
+    }
+
+    private var historySubtitle: String {
+        switch viewModel.historyEntryCount {
+        case 0: "No saved items"
+        case 1: "1 saved item"
+        default: "\(viewModel.historyEntryCount) saved items"
+        }
+    }
+
+    private var dictionarySubtitle: String {
+        switch viewModel.dictionarySearchCount {
+        case 0: "No recent searches"
+        case 1: "1 recent search"
+        default: "\(viewModel.dictionarySearchCount) recent searches"
+        }
+    }
+}
+
+// MARK: - Section Components
+
+private struct SettingsSectionCard<Content: View>: View {
+    let title: String
+    let iconName: String
+    @ViewBuilder let content: () -> Content
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 14) {
+            HStack(spacing: 8) {
+                Image(systemName: iconName)
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(AppTheme.teal)
+
+                Text(title.uppercased())
+                    .font(.system(size: 11, weight: .bold))
+                    .foregroundStyle(AppTheme.textMuted)
+            }
+
+            content()
+                .padding(16)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .background(Color(red: 0.98, green: 0.99, blue: 0.99))
+                .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+                .overlay(
+                    RoundedRectangle(cornerRadius: 12, style: .continuous)
+                        .stroke(AppTheme.border.opacity(0.85), lineWidth: 1)
+                )
+        }
+    }
+}
+
+private struct SettingsActionRow: View {
+    let iconName: String
+    let title: String
+    let subtitle: String
+    let actionTitle: String
+    var isDestructive = false
+    var isActionEnabled = true
+    let action: () -> Void
+
+    @State private var isActionHovered = false
+
+    var body: some View {
+        HStack(spacing: 12) {
+            SettingsRowIcon(systemName: iconName)
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text(title)
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Text(subtitle)
+                    .font(.system(size: 12))
+                    .foregroundStyle(AppTheme.textMuted)
+            }
+
+            Spacer(minLength: 8)
+
+            Button(action: action) {
+                Text(actionTitle)
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(actionForeground)
+                    .padding(.horizontal, 12)
+                    .padding(.vertical, 6)
+                    .background(actionBackground)
+                    .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 8, style: .continuous)
+                            .stroke(actionBorder, lineWidth: 1)
+                    )
+                    .scaleEffect(isActionHovered && isActionEnabled ? 1.02 : 1.0)
+                    .animation(.easeOut(duration: 0.15), value: isActionHovered)
+            }
+            .buttonStyle(.plain)
+            .disabled(!isActionEnabled)
+            .opacity(isActionEnabled ? 1 : 0.45)
+            .onHover { isActionHovered = $0 }
+        }
+        .padding(.vertical, 4)
+    }
+
+    private var actionForeground: Color {
+        guard isActionEnabled else { return AppTheme.textMuted }
+        if isDestructive {
+            return isActionHovered ? AppTheme.clearForegroundHover : AppTheme.clearForeground
+        }
+        return isActionHovered ? AppTheme.tealDark : AppTheme.teal
+    }
+
+    private var actionBackground: Color {
+        guard isActionEnabled else { return Color.white }
+        if isDestructive {
+            return isActionHovered ? AppTheme.clearBackgroundHover : AppTheme.clearBackground
+        }
+        return isActionHovered ? AppTheme.tealLight.opacity(0.85) : AppTheme.tealLight
+    }
+
+    private var actionBorder: Color {
+        guard isActionEnabled else { return AppTheme.border }
+        if isDestructive {
+            return isActionHovered ? AppTheme.clearForeground.opacity(0.35) : AppTheme.clearBorder
+        }
+        return isActionHovered ? AppTheme.toolbarBorderHover : Color.clear
+    }
+}
+
+private struct SettingsInfoRow: View {
+    let iconName: String
+    let title: String
+    let value: String
+
+    var body: some View {
+        HStack(spacing: 12) {
+            SettingsRowIcon(systemName: iconName)
+
+            Text(title)
+                .font(.system(size: 14, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            Spacer()
+
+            Text(value)
+                .font(.system(size: 13, weight: .medium))
+                .foregroundStyle(AppTheme.textMuted)
+        }
+        .padding(.vertical, 4)
+    }
+}
+
+private struct SettingsLinkRow: View {
+    let iconName: String
+    let title: String
+
+    @State private var isHovered = false
+
+    var body: some View {
+        Button {} label: {
+            HStack(spacing: 12) {
+                SettingsRowIcon(systemName: iconName)
+
+                Text(title)
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Spacer()
+
+                Image(systemName: "arrow.up.right")
+                    .font(.system(size: 11, weight: .semibold))
+                    .foregroundStyle(isHovered ? AppTheme.teal : AppTheme.textMuted)
+            }
+            .padding(.vertical, 4)
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
+    }
+}
+
+private struct SettingsRowIcon: View {
+    let systemName: String
+
+    var body: some View {
+        RoundedRectangle(cornerRadius: 8, style: .continuous)
+            .fill(AppTheme.tealLight)
+            .frame(width: 32, height: 32)
+            .overlay(
+                Image(systemName: systemName)
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(AppTheme.teal)
+            )
+    }
+}
+
+private struct SettingsRowDivider: View {
+    var body: some View {
+        Rectangle()
+            .fill(AppTheme.border.opacity(0.6))
+            .frame(height: 1)
+            .padding(.leading, 44)
+            .padding(.vertical, 10)
+    }
+}

+ 2 - 3
gramora/Views/SpellCheckerView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct SpellCheckerView: View {
     @StateObject private var viewModel = SpellCheckerViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     let restoreEntry: HistoryEntry?
     let onRestoreHandled: () -> Void
@@ -140,9 +141,7 @@ struct SpellCheckerView: View {
 
                 Spacer()
 
-                IconButton {
-                    SettingsGearIcon()
-                } action: {}
+                IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
                 .accessibilityLabel("Settings")
             }
         }

+ 2 - 3
gramora/Views/SummarizerView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct SummarizerView: View {
     @StateObject private var viewModel = SummarizerViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     var body: some View {
         GeometryReader { proxy in
@@ -120,9 +121,7 @@ struct SummarizerView: View {
 
                 Spacer()
 
-                IconButton {
-                    SettingsGearIcon()
-                } action: {}
+                IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
                 .accessibilityLabel("Settings")
             }
         }

+ 2 - 3
gramora/Views/WordsCountView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct WordsCountView: View {
     @StateObject private var viewModel = WordsCountViewModel()
+    @Environment(\.onSettingsTapped) private var onSettingsTapped
 
     var body: some View {
         GeometryReader { proxy in
@@ -59,9 +60,7 @@ struct WordsCountView: View {
 
             Spacer()
 
-            IconButton {
-                SettingsGearIcon()
-            } action: {}
+            IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
             .accessibilityLabel("Settings")
         }
     }