Ver Fonte

Add History page with persistent tool results and open, delete, and clear-all actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 há 1 mês atrás
pai
commit
9387a8ab88

+ 1 - 0
.gitignore

@@ -2,5 +2,6 @@
 *.xcuserstate
 *.xcuserstate
 xcuserdata/
 xcuserdata/
 DerivedData/
 DerivedData/
+.derivedData/
 build/
 build/
 gramora/Resources/Secrets.plist
 gramora/Resources/Secrets.plist

+ 2 - 2
gramora/Models/EmailWriterModels.swift

@@ -1,6 +1,6 @@
 import Foundation
 import Foundation
 
 
-enum EmailType: String, CaseIterable, Identifiable {
+enum EmailType: String, CaseIterable, Identifiable, Codable {
     case followUp
     case followUp
     case thankYou
     case thankYou
     case introduction
     case introduction
@@ -26,7 +26,7 @@ enum EmailType: String, CaseIterable, Identifiable {
     }
     }
 }
 }
 
 
-enum EmailTone: String, CaseIterable, Identifiable {
+enum EmailTone: String, CaseIterable, Identifiable, Codable {
     case professional
     case professional
     case friendly
     case friendly
     case formal
     case formal

+ 189 - 0
gramora/Models/HistoryModels.swift

@@ -0,0 +1,189 @@
+import Foundation
+
+enum HistoryTool: String, Codable, CaseIterable {
+    case grammarChecker
+    case spellingChecker
+    case languageTranslator
+    case aiEmailWriter
+    case journalFinder
+
+    var navigationDestination: NavigationDestination {
+        switch self {
+        case .grammarChecker: .grammarChecker
+        case .spellingChecker: .spellingChecker
+        case .languageTranslator: .languageTranslator
+        case .aiEmailWriter: .aiEmailWriter
+        case .journalFinder: .journalFinder
+        }
+    }
+
+    var title: String {
+        navigationDestination.title
+    }
+
+    var iconName: String {
+        navigationDestination.iconName
+    }
+}
+
+struct HistoryEntry: Identifiable, Codable, Equatable {
+    let id: UUID
+    let tool: HistoryTool
+    let createdAt: Date
+    let title: String
+    let preview: String
+    let payload: HistoryPayload
+
+    init(
+        id: UUID = UUID(),
+        tool: HistoryTool,
+        createdAt: Date = Date(),
+        title: String,
+        preview: String,
+        payload: HistoryPayload
+    ) {
+        self.id = id
+        self.tool = tool
+        self.createdAt = createdAt
+        self.title = title
+        self.preview = preview
+        self.payload = payload
+    }
+}
+
+enum HistoryPayload: Codable, Equatable {
+    case grammarChecker(GrammarCheckerHistoryData)
+    case spellingChecker(SpellCheckerHistoryData)
+    case languageTranslator(TranslationHistoryData)
+    case emailWriter(EmailWriterHistoryData)
+    case journalFinder(JournalFinderHistoryData)
+}
+
+struct GrammarCheckerHistoryData: Codable, Equatable {
+    let text: String
+    let issues: [StoredGrammarIssue]
+    let correctedText: String
+}
+
+struct StoredGrammarIssue: Codable, Equatable, Identifiable {
+    let id: UUID
+    let original: String
+    let suggestion: String
+    let explanation: String
+}
+
+struct SpellCheckerHistoryData: Codable, Equatable {
+    let text: String
+    let mistakes: [StoredSpellingMistake]
+    let correctedText: String
+}
+
+struct StoredSpellingMistake: Codable, Equatable, Identifiable {
+    let id: UUID
+    let original: String
+    let suggestion: String
+}
+
+struct TranslationHistoryData: Codable, Equatable {
+    let sourceText: String
+    let translatedText: String
+    let sourceLanguageID: String
+    let targetLanguageID: String
+}
+
+struct EmailWriterHistoryData: Codable, Equatable {
+    let subject: String
+    let prompt: String
+    let generatedEmail: String
+    let emailType: EmailType
+    let tone: EmailTone
+}
+
+struct JournalFinderHistoryData: Codable, Equatable {
+    let criteria: JournalFinderSearchCriteria
+    let result: StoredJournalFinderResult
+}
+
+struct StoredJournalFinderResult: Codable, Equatable {
+    let recommendations: [StoredJournalRecommendation]
+    let summary: String
+}
+
+struct StoredJournalRecommendation: Codable, Equatable, Identifiable {
+    let id: UUID
+    let name: String
+    let publisher: String
+    let matchScore: Int
+    let scope: String
+    let fitReason: String
+    let isOpenAccess: Bool
+    let indexes: [String]
+    let websiteURL: String?
+    let averageReviewWeeks: Int?
+}
+
+enum HistoryHelpers {
+    static func title(from text: String, fallback: String) -> String {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return fallback }
+
+        let firstLine = trimmed.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? trimmed
+        if firstLine.count <= 60 {
+            return firstLine
+        }
+        return String(firstLine.prefix(57)) + "..."
+    }
+
+    static func preview(from text: String) -> String {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return "No preview available" }
+
+        if trimmed.count <= 120 {
+            return trimmed
+        }
+        return String(trimmed.prefix(117)) + "..."
+    }
+}
+
+extension GrammarIssue {
+    var stored: StoredGrammarIssue {
+        StoredGrammarIssue(
+            id: id,
+            original: original,
+            suggestion: suggestion,
+            explanation: explanation
+        )
+    }
+}
+
+extension SpellingMistake {
+    var stored: StoredSpellingMistake {
+        StoredSpellingMistake(id: id, original: original, suggestion: suggestion)
+    }
+}
+
+extension JournalRecommendation {
+    var stored: StoredJournalRecommendation {
+        StoredJournalRecommendation(
+            id: id,
+            name: name,
+            publisher: publisher,
+            matchScore: matchScore,
+            scope: scope,
+            fitReason: fitReason,
+            isOpenAccess: isOpenAccess,
+            indexes: indexes,
+            websiteURL: websiteURL,
+            averageReviewWeeks: averageReviewWeeks
+        )
+    }
+}
+
+extension JournalFinderResult {
+    var stored: StoredJournalFinderResult {
+        StoredJournalFinderResult(
+            recommendations: recommendations.map(\.stored),
+            summary: summary
+        )
+    }
+}

+ 3 - 3
gramora/Models/JournalFinderModels.swift

@@ -1,6 +1,6 @@
 import Foundation
 import Foundation
 
 
-struct JournalFinderSearchCriteria: Equatable {
+struct JournalFinderSearchCriteria: Equatable, Codable {
     var title: String = ""
     var title: String = ""
     var abstract: String = ""
     var abstract: String = ""
     var keywords: String = ""
     var keywords: String = ""
@@ -9,7 +9,7 @@ struct JournalFinderSearchCriteria: Equatable {
     var selectedIndexes: Set<JournalIndex> = []
     var selectedIndexes: Set<JournalIndex> = []
 }
 }
 
 
-enum ResearchField: String, CaseIterable, Identifiable {
+enum ResearchField: String, CaseIterable, Identifiable, Codable {
     case all
     case all
     case computerScience = "Computer Science"
     case computerScience = "Computer Science"
     case medicine = "Medicine & Health"
     case medicine = "Medicine & Health"
@@ -31,7 +31,7 @@ enum ResearchField: String, CaseIterable, Identifiable {
     }
     }
 }
 }
 
 
-enum JournalIndex: String, CaseIterable, Identifiable {
+enum JournalIndex: String, CaseIterable, Identifiable, Codable {
     case scopus = "Scopus"
     case scopus = "Scopus"
     case webOfScience = "Web of Science"
     case webOfScience = "Web of Science"
     case pubmed = "PubMed"
     case pubmed = "PubMed"

+ 59 - 0
gramora/Services/HistoryStorageService.swift

@@ -0,0 +1,59 @@
+import Foundation
+
+extension Notification.Name {
+    static let historyDidChange = Notification.Name("historyDidChange")
+}
+
+protocol HistoryStoring {
+    func loadEntries() -> [HistoryEntry]
+    func save(_ entry: HistoryEntry)
+    func delete(id: UUID)
+    func clearAll()
+}
+
+final class HistoryStorageService: HistoryStoring {
+    static let shared = HistoryStorageService()
+
+    private let defaults: UserDefaults
+    private let storageKey = "gramora.history.entries"
+    private let maxEntries = 100
+
+    init(defaults: UserDefaults = .standard) {
+        self.defaults = defaults
+    }
+
+    func loadEntries() -> [HistoryEntry] {
+        guard
+            let data = defaults.data(forKey: storageKey),
+            let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data)
+        else {
+            return []
+        }
+
+        return entries.sorted { $0.createdAt > $1.createdAt }
+    }
+
+    func save(_ entry: HistoryEntry) {
+        var entries = loadEntries()
+        entries.removeAll { $0.id == entry.id }
+        entries.insert(entry, at: 0)
+        entries = Array(entries.prefix(maxEntries))
+        persist(entries)
+    }
+
+    func delete(id: UUID) {
+        var entries = loadEntries()
+        entries.removeAll { $0.id == id }
+        persist(entries)
+    }
+
+    func clearAll() {
+        persist([])
+    }
+
+    private func persist(_ entries: [HistoryEntry]) {
+        guard let data = try? JSONEncoder().encode(entries) else { return }
+        defaults.set(data, forKey: storageKey)
+        NotificationCenter.default.post(name: .historyDidChange, object: nil)
+    }
+}

+ 43 - 0
gramora/ViewModels/EmailWriterViewModel.swift

@@ -13,6 +13,12 @@ final class EmailWriterViewModel: ObservableObject {
     @Published var tone: EmailTone = .professional
     @Published var tone: EmailTone = .professional
     @Published var isGenerating = false
     @Published var isGenerating = false
 
 
+    private let historyStorage: HistoryStoring
+
+    init(historyStorage: HistoryStoring = HistoryStorageService.shared) {
+        self.historyStorage = historyStorage
+    }
+
     var characterCount: Int { prompt.count }
     var characterCount: Int { prompt.count }
 
 
     var characterCountLabel: String {
     var characterCountLabel: String {
@@ -66,5 +72,42 @@ final class EmailWriterViewModel: ObservableObject {
 
 
         generatedEmail = email
         generatedEmail = email
         isGenerating = false
         isGenerating = false
+        saveToHistory(
+            subject: subjectLine,
+            prompt: trimmedPrompt,
+            generatedEmail: email
+        )
+    }
+
+    func restore(from entry: HistoryEntry) {
+        guard case .emailWriter(let data) = entry.payload else { return }
+
+        subject = data.subject
+        prompt = data.prompt
+        generatedEmail = data.generatedEmail
+        emailType = data.emailType
+        tone = data.tone
+    }
+
+    private func saveToHistory(subject: String, prompt: String, generatedEmail: String) {
+        let payload = HistoryPayload.emailWriter(
+            EmailWriterHistoryData(
+                subject: subject,
+                prompt: prompt,
+                generatedEmail: generatedEmail,
+                emailType: emailType,
+                tone: tone
+            )
+        )
+
+        let titleSource = subject.isEmpty ? prompt : subject
+        let entry = HistoryEntry(
+            tool: .aiEmailWriter,
+            title: HistoryHelpers.title(from: titleSource, fallback: "Generated Email"),
+            preview: HistoryHelpers.preview(from: generatedEmail),
+            payload: payload
+        )
+
+        historyStorage.save(entry)
     }
     }
 }
 }

+ 38 - 1
gramora/ViewModels/GrammarCheckerViewModel.swift

@@ -14,9 +14,14 @@ final class GrammarCheckerViewModel: ObservableObject {
     @Published private(set) var errorMessage: String?
     @Published private(set) var errorMessage: String?
 
 
     private let grammarCheckService: any GrammarCheckServicing
     private let grammarCheckService: any GrammarCheckServicing
+    private let historyStorage: HistoryStoring
 
 
-    init(grammarCheckService: any GrammarCheckServicing = GrammarCheckService()) {
+    init(
+        grammarCheckService: any GrammarCheckServicing = GrammarCheckService(),
+        historyStorage: HistoryStoring = HistoryStorageService.shared
+    ) {
         self.grammarCheckService = grammarCheckService
         self.grammarCheckService = grammarCheckService
+        self.historyStorage = historyStorage
     }
     }
 
 
     var characterCount: Int { text.count }
     var characterCount: Int { text.count }
@@ -68,6 +73,7 @@ final class GrammarCheckerViewModel: ObservableObject {
                 issues = result.issues
                 issues = result.issues
                 correctedText = result.correctedText
                 correctedText = result.correctedText
                 hasResults = true
                 hasResults = true
+                saveToHistory(text: content, issues: result.issues, correctedText: result.correctedText)
             } catch {
             } catch {
                 errorMessage = error.localizedDescription
                 errorMessage = error.localizedDescription
             }
             }
@@ -81,4 +87,35 @@ final class GrammarCheckerViewModel: ObservableObject {
         NSPasteboard.general.clearContents()
         NSPasteboard.general.clearContents()
         NSPasteboard.general.setString(correctedText, forType: .string)
         NSPasteboard.general.setString(correctedText, forType: .string)
     }
     }
+
+    func restore(from entry: HistoryEntry) {
+        guard case .grammarChecker(let data) = entry.payload else { return }
+
+        text = data.text
+        issues = data.issues.map {
+            GrammarIssue(original: $0.original, suggestion: $0.suggestion, explanation: $0.explanation)
+        }
+        correctedText = data.correctedText
+        hasResults = true
+        errorMessage = nil
+    }
+
+    private func saveToHistory(text: String, issues: [GrammarIssue], correctedText: String) {
+        let payload = HistoryPayload.grammarChecker(
+            GrammarCheckerHistoryData(
+                text: text,
+                issues: issues.map(\.stored),
+                correctedText: correctedText
+            )
+        )
+
+        let entry = HistoryEntry(
+            tool: .grammarChecker,
+            title: HistoryHelpers.title(from: text, fallback: "Grammar Check"),
+            preview: HistoryHelpers.preview(from: correctedText.isEmpty ? text : correctedText),
+            payload: payload
+        )
+
+        historyStorage.save(entry)
+    }
 }
 }

+ 55 - 0
gramora/ViewModels/HistoryViewModel.swift

@@ -0,0 +1,55 @@
+import Combine
+import Foundation
+
+@MainActor
+final class HistoryViewModel: ObservableObject {
+    @Published private(set) var entries: [HistoryEntry] = []
+    @Published var isShowingClearConfirmation = false
+    @Published var entryPendingDeletion: HistoryEntry?
+
+    private let storage: HistoryStoring
+    private var cancellables = Set<AnyCancellable>()
+
+    init(storage: HistoryStoring = HistoryStorageService.shared) {
+        self.storage = storage
+        reload()
+
+        NotificationCenter.default.publisher(for: .historyDidChange)
+            .receive(on: RunLoop.main)
+            .sink { [weak self] _ in
+                self?.reload()
+            }
+            .store(in: &cancellables)
+    }
+
+    var isEmpty: Bool { entries.isEmpty }
+
+    func reload() {
+        entries = storage.loadEntries()
+    }
+
+    func delete(_ entry: HistoryEntry) {
+        storage.delete(id: entry.id)
+        reload()
+    }
+
+    func confirmDelete(_ entry: HistoryEntry) {
+        entryPendingDeletion = entry
+    }
+
+    func performPendingDeletion() {
+        guard let entry = entryPendingDeletion else { return }
+        delete(entry)
+        entryPendingDeletion = nil
+    }
+
+    func clearAll() {
+        storage.clearAll()
+        reload()
+        isShowingClearConfirmation = false
+    }
+
+    func formattedDate(for entry: HistoryEntry) -> String {
+        entry.createdAt.formatted(date: .abbreviated, time: .shortened)
+    }
+}

+ 52 - 1
gramora/ViewModels/JournalFinderViewModel.swift

@@ -21,9 +21,14 @@ final class JournalFinderViewModel: ObservableObject {
     @Published private(set) var errorMessage: String?
     @Published private(set) var errorMessage: String?
 
 
     private let service: JournalFinderServicing
     private let service: JournalFinderServicing
+    private let historyStorage: HistoryStoring
 
 
-    init(service: JournalFinderServicing = JournalFinderService()) {
+    init(
+        service: JournalFinderServicing = JournalFinderService(),
+        historyStorage: HistoryStoring = HistoryStorageService.shared
+    ) {
         self.service = service
         self.service = service
+        self.historyStorage = historyStorage
     }
     }
 
 
     var abstractCharacterCount: Int { abstractText.count }
     var abstractCharacterCount: Int { abstractText.count }
@@ -93,6 +98,7 @@ final class JournalFinderViewModel: ObservableObject {
                 let searchResult = try await service.findJournals(for: searchCriteria)
                 let searchResult = try await service.findJournals(for: searchCriteria)
                 result = searchResult
                 result = searchResult
                 hasResults = true
                 hasResults = true
+                saveToHistory(criteria: searchCriteria, result: searchResult)
             } catch {
             } catch {
                 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
                 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
             }
             }
@@ -108,4 +114,49 @@ final class JournalFinderViewModel: ObservableObject {
         else { return }
         else { return }
         NSWorkspace.shared.open(url)
         NSWorkspace.shared.open(url)
     }
     }
+
+    func restore(from entry: HistoryEntry) {
+        guard case .journalFinder(let data) = entry.payload else { return }
+
+        title = data.criteria.title
+        abstractText = data.criteria.abstract
+        keywords = data.criteria.keywords
+        field = data.criteria.field
+        openAccessOnly = data.criteria.openAccessOnly
+        selectedIndexes = data.criteria.selectedIndexes
+        result = JournalFinderResult(
+            recommendations: data.result.recommendations.map {
+                JournalRecommendation(
+                    name: $0.name,
+                    publisher: $0.publisher,
+                    matchScore: $0.matchScore,
+                    scope: $0.scope,
+                    fitReason: $0.fitReason,
+                    isOpenAccess: $0.isOpenAccess,
+                    indexes: $0.indexes,
+                    websiteURL: $0.websiteURL,
+                    averageReviewWeeks: $0.averageReviewWeeks
+                )
+            },
+            summary: data.result.summary
+        )
+        hasResults = true
+        errorMessage = nil
+    }
+
+    private func saveToHistory(criteria: JournalFinderSearchCriteria, result: JournalFinderResult) {
+        let titleSource = criteria.title.isEmpty ? criteria.abstract : criteria.title
+        let payload = HistoryPayload.journalFinder(
+            JournalFinderHistoryData(criteria: criteria, result: result.stored)
+        )
+
+        let entry = HistoryEntry(
+            tool: .journalFinder,
+            title: HistoryHelpers.title(from: titleSource, fallback: "Journal Search"),
+            preview: HistoryHelpers.preview(from: result.summary),
+            payload: payload
+        )
+
+        historyStorage.save(entry)
+    }
 }
 }

+ 36 - 0
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -13,6 +13,12 @@ final class LanguageTranslatorViewModel: ObservableObject {
     @Published var targetLanguage: TranslationLanguage = .defaultTarget
     @Published var targetLanguage: TranslationLanguage = .defaultTarget
     @Published var isTranslating = false
     @Published var isTranslating = false
 
 
+    private let historyStorage: HistoryStoring
+
+    init(historyStorage: HistoryStoring = HistoryStorageService.shared) {
+        self.historyStorage = historyStorage
+    }
+
     var characterCount: Int { sourceText.count }
     var characterCount: Int { sourceText.count }
 
 
     var characterCountLabel: String {
     var characterCountLabel: String {
@@ -78,6 +84,36 @@ final class LanguageTranslatorViewModel: ObservableObject {
         // Translation service integration point
         // Translation service integration point
         translatedText = "[\(sourceLanguage.name) → \(targetLanguage.name)] \(trimmed)"
         translatedText = "[\(sourceLanguage.name) → \(targetLanguage.name)] \(trimmed)"
         isTranslating = false
         isTranslating = false
+        saveToHistory(sourceText: trimmed, translatedText: translatedText)
+    }
+
+    func restore(from entry: HistoryEntry) {
+        guard case .languageTranslator(let data) = entry.payload else { return }
+
+        sourceText = data.sourceText
+        translatedText = data.translatedText
+        sourceLanguage = TranslationLanguage.all.first { $0.id == data.sourceLanguageID } ?? .defaultSource
+        targetLanguage = TranslationLanguage.all.first { $0.id == data.targetLanguageID } ?? .defaultTarget
+    }
+
+    private func saveToHistory(sourceText: String, translatedText: String) {
+        let payload = HistoryPayload.languageTranslator(
+            TranslationHistoryData(
+                sourceText: sourceText,
+                translatedText: translatedText,
+                sourceLanguageID: sourceLanguage.id,
+                targetLanguageID: targetLanguage.id
+            )
+        )
+
+        let entry = HistoryEntry(
+            tool: .languageTranslator,
+            title: HistoryHelpers.title(from: sourceText, fallback: "Translation"),
+            preview: HistoryHelpers.preview(from: translatedText),
+            payload: payload
+        )
+
+        historyStorage.save(entry)
     }
     }
 
 
     func syncTargetLanguageIfNeeded() {
     func syncTargetLanguageIfNeeded() {

+ 10 - 0
gramora/ViewModels/MainViewModel.swift

@@ -5,6 +5,7 @@ import Foundation
 final class MainViewModel: ObservableObject {
 final class MainViewModel: ObservableObject {
     @Published var selectedDestination: NavigationDestination = .grammarChecker
     @Published var selectedDestination: NavigationDestination = .grammarChecker
     @Published var isShowingPaywall = false
     @Published var isShowingPaywall = false
+    @Published var pendingHistoryEntry: HistoryEntry?
 
 
     func showPaywall() {
     func showPaywall() {
         isShowingPaywall = true
         isShowingPaywall = true
@@ -13,4 +14,13 @@ final class MainViewModel: ObservableObject {
     func hidePaywall() {
     func hidePaywall() {
         isShowingPaywall = false
         isShowingPaywall = false
     }
     }
+
+    func openHistoryEntry(_ entry: HistoryEntry) {
+        pendingHistoryEntry = entry
+        selectedDestination = entry.tool.navigationDestination
+    }
+
+    func clearPendingHistoryEntry() {
+        pendingHistoryEntry = nil
+    }
 }
 }

+ 37 - 0
gramora/ViewModels/SpellCheckerViewModel.swift

@@ -11,6 +11,12 @@ final class SpellCheckerViewModel: ObservableObject {
     @Published private(set) var correctedText: String = ""
     @Published private(set) var correctedText: String = ""
     @Published private(set) var hasResults = false
     @Published private(set) var hasResults = false
 
 
+    private let historyStorage: HistoryStoring
+
+    init(historyStorage: HistoryStoring = HistoryStorageService.shared) {
+        self.historyStorage = historyStorage
+    }
+
     var characterCount: Int { text.count }
     var characterCount: Int { text.count }
 
 
     var characterCountLabel: String {
     var characterCountLabel: String {
@@ -88,6 +94,37 @@ final class SpellCheckerViewModel: ObservableObject {
         mistakes = foundMistakes
         mistakes = foundMistakes
         correctedText = mutable as String
         correctedText = mutable as String
         hasResults = true
         hasResults = true
+        saveToHistory(text: content, mistakes: foundMistakes, correctedText: mutable as String)
+    }
+
+    func restore(from entry: HistoryEntry) {
+        guard case .spellingChecker(let data) = entry.payload else { return }
+
+        text = data.text
+        mistakes = data.mistakes.map {
+            SpellingMistake(original: $0.original, suggestion: $0.suggestion)
+        }
+        correctedText = data.correctedText
+        hasResults = true
+    }
+
+    private func saveToHistory(text: String, mistakes: [SpellingMistake], correctedText: String) {
+        let payload = HistoryPayload.spellingChecker(
+            SpellCheckerHistoryData(
+                text: text,
+                mistakes: mistakes.map(\.stored),
+                correctedText: correctedText
+            )
+        )
+
+        let entry = HistoryEntry(
+            tool: .spellingChecker,
+            title: HistoryHelpers.title(from: text, fallback: "Spelling Check"),
+            preview: HistoryHelpers.preview(from: correctedText),
+            payload: payload
+        )
+
+        historyStorage.save(entry)
     }
     }
 
 
     func copyCorrectedText() {
     func copyCorrectedText() {

+ 15 - 0
gramora/Views/EmailWriterView.swift

@@ -3,6 +3,14 @@ import SwiftUI
 struct EmailWriterView: View {
 struct EmailWriterView: View {
     @StateObject private var viewModel = EmailWriterViewModel()
     @StateObject private var viewModel = EmailWriterViewModel()
 
 
+    let restoreEntry: HistoryEntry?
+    let onRestoreHandled: () -> Void
+
+    init(restoreEntry: HistoryEntry? = nil, onRestoreHandled: @escaping () -> Void = {}) {
+        self.restoreEntry = restoreEntry
+        self.onRestoreHandled = onRestoreHandled
+    }
+
     var body: some View {
     var body: some View {
         GeometryReader { proxy in
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -34,6 +42,13 @@ struct EmailWriterView: View {
                 viewModel.prompt = String(newValue.prefix(EmailWriterViewModel.maxCharacters))
                 viewModel.prompt = String(newValue.prefix(EmailWriterViewModel.maxCharacters))
             }
             }
         }
         }
+        .onAppear(perform: restoreIfNeeded)
+    }
+
+    private func restoreIfNeeded() {
+        guard let restoreEntry else { return }
+        viewModel.restore(from: restoreEntry)
+        onRestoreHandled()
     }
     }
 
 
     private var header: some View {
     private var header: some View {

+ 15 - 0
gramora/Views/GrammarCheckerView.swift

@@ -3,6 +3,14 @@ import SwiftUI
 struct GrammarCheckerView: View {
 struct GrammarCheckerView: View {
     @StateObject private var viewModel = GrammarCheckerViewModel()
     @StateObject private var viewModel = GrammarCheckerViewModel()
 
 
+    let restoreEntry: HistoryEntry?
+    let onRestoreHandled: () -> Void
+
+    init(restoreEntry: HistoryEntry? = nil, onRestoreHandled: @escaping () -> Void = {}) {
+        self.restoreEntry = restoreEntry
+        self.onRestoreHandled = onRestoreHandled
+    }
+
     private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
     private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
 
 
     var body: some View {
     var body: some View {
@@ -64,6 +72,13 @@ struct GrammarCheckerView: View {
                 viewModel.resetResults()
                 viewModel.resetResults()
             }
             }
         }
         }
+        .onAppear(perform: restoreIfNeeded)
+    }
+
+    private func restoreIfNeeded() {
+        guard let restoreEntry else { return }
+        viewModel.restore(from: restoreEntry)
+        onRestoreHandled()
     }
     }
 
 
     private var header: some View {
     private var header: some View {

+ 165 - 0
gramora/Views/HistoryView.swift

@@ -0,0 +1,165 @@
+import SwiftUI
+
+struct HistoryView: View {
+    @StateObject private var viewModel = HistoryViewModel()
+    let onOpenEntry: (HistoryEntry) -> Void
+
+    var body: some View {
+        GeometryReader { proxy in
+            let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
+            let dynamicTopPadding = min(max(proxy.size.height * 0.015, 8), 16)
+            let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
+
+            ScrollView(.vertical, showsIndicators: false) {
+                VStack(alignment: .leading, spacing: 0) {
+                    header
+                        .padding(.bottom, 24)
+
+                    if viewModel.isEmpty {
+                        emptyState
+                    } else {
+                        LazyVStack(spacing: 12) {
+                            ForEach(viewModel.entries) { entry in
+                                historyRow(entry)
+                            }
+                        }
+                    }
+                }
+                .frame(maxWidth: 900, alignment: .topLeading)
+                .frame(maxWidth: .infinity, alignment: .topLeading)
+                .padding(.horizontal, dynamicHorizontalPadding)
+                .padding(.top, dynamicTopPadding)
+                .padding(.bottom, dynamicBottomPadding)
+            }
+        }
+        .confirmationDialog(
+            "Clear all history?",
+            isPresented: $viewModel.isShowingClearConfirmation,
+            titleVisibility: .visible
+        ) {
+            Button("Clear All", role: .destructive) {
+                viewModel.clearAll()
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("This will permanently remove all saved history items.")
+        }
+        .confirmationDialog(
+            "Delete this history item?",
+            isPresented: Binding(
+                get: { viewModel.entryPendingDeletion != nil },
+                set: { if !$0 { viewModel.entryPendingDeletion = nil } }
+            ),
+            titleVisibility: .visible
+        ) {
+            Button("Delete", role: .destructive) {
+                viewModel.performPendingDeletion()
+            }
+            Button("Cancel", role: .cancel) {
+                viewModel.entryPendingDeletion = nil
+            }
+        }
+    }
+
+    private var header: some View {
+        HStack(alignment: .top) {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("History")
+                    .font(.system(size: 30, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Text("Review, reopen, or remove your past tool results.")
+                    .font(.system(size: 15))
+                    .foregroundStyle(AppTheme.teal)
+            }
+
+            Spacer()
+
+            if !viewModel.isEmpty {
+                ClearButton {
+                    viewModel.isShowingClearConfirmation = true
+                }
+                .accessibilityLabel("Clear all history")
+            }
+        }
+    }
+
+    private var emptyState: some View {
+        VStack(spacing: 14) {
+            Image(systemName: "clock")
+                .font(.system(size: 40))
+                .foregroundStyle(AppTheme.teal.opacity(0.45))
+
+            Text("No history yet")
+                .font(.system(size: 20, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            Text("Results from tools like Grammar Checker and Journal Finder will appear here.")
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textSecondary)
+                .multilineTextAlignment(.center)
+                .frame(maxWidth: 360)
+        }
+        .frame(maxWidth: .infinity)
+        .padding(.vertical, 80)
+    }
+
+    private func historyRow(_ entry: HistoryEntry) -> some View {
+        HStack(alignment: .top, spacing: 14) {
+            ZStack {
+                RoundedRectangle(cornerRadius: 10, style: .continuous)
+                    .fill(AppTheme.tealLight)
+                    .frame(width: 40, height: 40)
+
+                Image(systemName: entry.tool.iconName)
+                    .font(.system(size: 16, weight: .semibold))
+                    .foregroundStyle(AppTheme.teal)
+            }
+
+            VStack(alignment: .leading, spacing: 6) {
+                HStack(alignment: .firstTextBaseline, spacing: 8) {
+                    Text(entry.title)
+                        .font(.system(size: 15, weight: .semibold))
+                        .foregroundStyle(AppTheme.textPrimary)
+                        .lineLimit(1)
+
+                    Spacer(minLength: 8)
+
+                    Text(viewModel.formattedDate(for: entry))
+                        .font(.system(size: 12))
+                        .foregroundStyle(AppTheme.textMuted)
+                }
+
+                Text(entry.tool.title)
+                    .font(.system(size: 12, weight: .medium))
+                    .foregroundStyle(AppTheme.teal)
+
+                Text(entry.preview)
+                    .font(.system(size: 13))
+                    .foregroundStyle(AppTheme.textSecondary)
+                    .lineLimit(2)
+                    .fixedSize(horizontal: false, vertical: true)
+            }
+
+            HStack(spacing: 4) {
+                IconButton(iconName: "arrow.up.right.square") {
+                    onOpenEntry(entry)
+                }
+                .accessibilityLabel("Open history item")
+
+                IconButton(iconName: "trash") {
+                    viewModel.confirmDelete(entry)
+                }
+                .accessibilityLabel("Delete history item")
+            }
+        }
+        .padding(16)
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius, style: .continuous))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius, style: .continuous)
+                .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: 8, y: 2)
+    }
+}

+ 15 - 0
gramora/Views/JournalFinderView.swift

@@ -3,6 +3,14 @@ import SwiftUI
 struct JournalFinderView: View {
 struct JournalFinderView: View {
     @StateObject private var viewModel = JournalFinderViewModel()
     @StateObject private var viewModel = JournalFinderViewModel()
 
 
+    let restoreEntry: HistoryEntry?
+    let onRestoreHandled: () -> Void
+
+    init(restoreEntry: HistoryEntry? = nil, onRestoreHandled: @escaping () -> Void = {}) {
+        self.restoreEntry = restoreEntry
+        self.onRestoreHandled = onRestoreHandled
+    }
+
     private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
     private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
 
 
     var body: some View {
     var body: some View {
@@ -48,6 +56,13 @@ struct JournalFinderView: View {
             }
             }
             if viewModel.hasResults { viewModel.resetResults() }
             if viewModel.hasResults { viewModel.resetResults() }
         }
         }
+        .onAppear(perform: restoreIfNeeded)
+    }
+
+    private func restoreIfNeeded() {
+        guard let restoreEntry else { return }
+        viewModel.restore(from: restoreEntry)
+        onRestoreHandled()
     }
     }
 
 
     // MARK: - Search Layout
     // MARK: - Search Layout

+ 15 - 0
gramora/Views/LanguageTranslatorView.swift

@@ -3,6 +3,14 @@ import SwiftUI
 struct LanguageTranslatorView: View {
 struct LanguageTranslatorView: View {
     @StateObject private var viewModel = LanguageTranslatorViewModel()
     @StateObject private var viewModel = LanguageTranslatorViewModel()
 
 
+    let restoreEntry: HistoryEntry?
+    let onRestoreHandled: () -> Void
+
+    init(restoreEntry: HistoryEntry? = nil, onRestoreHandled: @escaping () -> Void = {}) {
+        self.restoreEntry = restoreEntry
+        self.onRestoreHandled = onRestoreHandled
+    }
+
     var body: some View {
     var body: some View {
         GeometryReader { proxy in
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -30,6 +38,13 @@ struct LanguageTranslatorView: View {
         .onChange(of: viewModel.sourceLanguage) { _ in
         .onChange(of: viewModel.sourceLanguage) { _ in
             viewModel.syncTargetLanguageIfNeeded()
             viewModel.syncTargetLanguageIfNeeded()
         }
         }
+        .onAppear(perform: restoreIfNeeded)
+    }
+
+    private func restoreIfNeeded() {
+        guard let restoreEntry else { return }
+        viewModel.restore(from: restoreEntry)
+        onRestoreHandled()
     }
     }
 
 
     private var header: some View {
     private var header: some View {

+ 32 - 7
gramora/Views/MainView.swift

@@ -51,27 +51,52 @@ struct MainView: View {
     private var contentView: some View {
     private var contentView: some View {
         switch viewModel.selectedDestination {
         switch viewModel.selectedDestination {
         case .grammarChecker:
         case .grammarChecker:
-            GrammarCheckerView()
+            GrammarCheckerView(
+                restoreEntry: historyEntry(for: .grammarChecker),
+                onRestoreHandled: viewModel.clearPendingHistoryEntry
+            )
         case .summarizer:
         case .summarizer:
             SummarizerView()
             SummarizerView()
         case .paraphrasing:
         case .paraphrasing:
             ParaphrasingView()
             ParaphrasingView()
         case .languageTranslator:
         case .languageTranslator:
-            LanguageTranslatorView()
+            LanguageTranslatorView(
+                restoreEntry: historyEntry(for: .languageTranslator),
+                onRestoreHandled: viewModel.clearPendingHistoryEntry
+            )
         case .punctuationChecker:
         case .punctuationChecker:
             PunctuationCheckerView()
             PunctuationCheckerView()
         case .wordsCount:
         case .wordsCount:
             WordsCountView()
             WordsCountView()
         case .aiEmailWriter:
         case .aiEmailWriter:
-            EmailWriterView()
+            EmailWriterView(
+                restoreEntry: historyEntry(for: .aiEmailWriter),
+                onRestoreHandled: viewModel.clearPendingHistoryEntry
+            )
         case .spellingChecker:
         case .spellingChecker:
-            SpellCheckerView()
+            SpellCheckerView(
+                restoreEntry: historyEntry(for: .spellingChecker),
+                onRestoreHandled: viewModel.clearPendingHistoryEntry
+            )
         case .dictionary:
         case .dictionary:
             DictionaryView()
             DictionaryView()
         case .journalFinder:
         case .journalFinder:
-            JournalFinderView()
-        default:
-            PlaceholderView(destination: viewModel.selectedDestination)
+            JournalFinderView(
+                restoreEntry: historyEntry(for: .journalFinder),
+                onRestoreHandled: viewModel.clearPendingHistoryEntry
+            )
+        case .history:
+            HistoryView(onOpenEntry: viewModel.openHistoryEntry)
         }
         }
     }
     }
+
+    private func historyEntry(for tool: HistoryTool) -> HistoryEntry? {
+        guard
+            let entry = viewModel.pendingHistoryEntry,
+            entry.tool == tool
+        else {
+            return nil
+        }
+        return entry
+    }
 }
 }

+ 15 - 0
gramora/Views/SpellCheckerView.swift

@@ -3,6 +3,14 @@ import SwiftUI
 struct SpellCheckerView: View {
 struct SpellCheckerView: View {
     @StateObject private var viewModel = SpellCheckerViewModel()
     @StateObject private var viewModel = SpellCheckerViewModel()
 
 
+    let restoreEntry: HistoryEntry?
+    let onRestoreHandled: () -> Void
+
+    init(restoreEntry: HistoryEntry? = nil, onRestoreHandled: @escaping () -> Void = {}) {
+        self.restoreEntry = restoreEntry
+        self.onRestoreHandled = onRestoreHandled
+    }
+
     private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
     private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
 
 
     var body: some View {
     var body: some View {
@@ -56,6 +64,13 @@ struct SpellCheckerView: View {
                 viewModel.resetResults()
                 viewModel.resetResults()
             }
             }
         }
         }
+        .onAppear(perform: restoreIfNeeded)
+    }
+
+    private func restoreIfNeeded() {
+        guard let restoreEntry else { return }
+        viewModel.restore(from: restoreEntry)
+        onRestoreHandled()
     }
     }
 
 
     private var header: some View {
     private var header: some View {