Bläddra i källkod

Add Dictionary page with word lookup, definitions, and pronunciation.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 månad sedan
förälder
incheckning
46a2131822

+ 43 - 0
gramora/Models/DictionaryModels.swift

@@ -0,0 +1,43 @@
+import Foundation
+
+struct DictionaryEntry: Identifiable, Equatable {
+    let id = UUID()
+    let word: String
+    let phonetic: String?
+    let audioURL: URL?
+    let meanings: [DictionaryMeaning]
+}
+
+struct DictionaryMeaning: Identifiable, Equatable {
+    let id = UUID()
+    let partOfSpeech: String
+    let definitions: [DictionaryDefinition]
+    let synonyms: [String]
+    let antonyms: [String]
+}
+
+struct DictionaryDefinition: Identifiable, Equatable {
+    let id = UUID()
+    let text: String
+    let example: String?
+}
+
+enum DictionaryServiceError: LocalizedError {
+    case emptyInput
+    case wordNotFound
+    case invalidResponse
+    case networkError(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .emptyInput:
+            "Enter a word to look up."
+        case .wordNotFound:
+            "No definition found for that word."
+        case .invalidResponse:
+            "Could not read the dictionary response."
+        case .networkError(let message):
+            message
+        }
+    }
+}

+ 95 - 0
gramora/Services/DictionaryService.swift

@@ -0,0 +1,95 @@
+import Foundation
+
+protocol DictionaryServicing {
+    func lookUp(word: String) async throws -> DictionaryEntry
+}
+
+struct DictionaryService: DictionaryServicing {
+    private let session: URLSession
+
+    init(session: URLSession = .shared) {
+        self.session = session
+    }
+
+    func lookUp(word: String) async throws -> DictionaryEntry {
+        let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw DictionaryServiceError.emptyInput
+        }
+
+        let encodedWord = trimmed.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? trimmed
+        guard let url = URL(string: "https://api.dictionaryapi.dev/api/v2/entries/en/\(encodedWord)") else {
+            throw DictionaryServiceError.invalidResponse
+        }
+
+        let (data, response) = try await session.data(from: url)
+
+        if let httpResponse = response as? HTTPURLResponse {
+            if httpResponse.statusCode == 404 {
+                throw DictionaryServiceError.wordNotFound
+            }
+            if !(200...299).contains(httpResponse.statusCode) {
+                throw DictionaryServiceError.networkError("Request failed with status \(httpResponse.statusCode).")
+            }
+        }
+
+        let entries = try JSONDecoder().decode([DictionaryAPIEntry].self, from: data)
+        guard let entry = entries.first else {
+            throw DictionaryServiceError.wordNotFound
+        }
+
+        return entry.toDictionaryEntry()
+    }
+}
+
+private struct DictionaryAPIEntry: Decodable {
+    struct Phonetic: Decodable {
+        let text: String?
+        let audio: String?
+    }
+
+    struct Meaning: Decodable {
+        struct Definition: Decodable {
+            let definition: String
+            let example: String?
+            let synonyms: [String]?
+            let antonyms: [String]?
+        }
+
+        let partOfSpeech: String
+        let definitions: [Definition]
+        let synonyms: [String]?
+        let antonyms: [String]?
+    }
+
+    let word: String
+    let phonetics: [Phonetic]
+    let meanings: [Meaning]
+
+    func toDictionaryEntry() -> DictionaryEntry {
+        let phonetic = phonetics.first(where: { !($0.text ?? "").isEmpty })?.text
+        let audioString = phonetics.first(where: { !($0.audio ?? "").isEmpty })?.audio
+        let audioURL = audioString.flatMap { URL(string: $0) }
+
+        let mappedMeanings = meanings.map { meaning in
+            DictionaryMeaning(
+                partOfSpeech: meaning.partOfSpeech,
+                definitions: meaning.definitions.map {
+                    DictionaryDefinition(
+                        text: $0.definition,
+                        example: $0.example
+                    )
+                },
+                synonyms: Array(Set((meaning.synonyms ?? []) + meaning.definitions.flatMap { $0.synonyms ?? [] })).sorted(),
+                antonyms: Array(Set((meaning.antonyms ?? []) + meaning.definitions.flatMap { $0.antonyms ?? [] })).sorted()
+            )
+        }
+
+        return DictionaryEntry(
+            word: word,
+            phonetic: phonetic,
+            audioURL: audioURL,
+            meanings: mappedMeanings
+        )
+    }
+}

+ 133 - 0
gramora/ViewModels/DictionaryViewModel.swift

@@ -0,0 +1,133 @@
+import AppKit
+import AVFoundation
+import Combine
+import Foundation
+
+@MainActor
+final class DictionaryViewModel: ObservableObject {
+    static let maxQueryLength = 64
+    private static let recentSearchesKey = "dictionaryRecentSearches"
+    private static let maxRecentSearches = 8
+
+    @Published var query: String = ""
+    @Published private(set) var entry: DictionaryEntry?
+    @Published private(set) var isLoading = false
+    @Published private(set) var errorMessage: String?
+    @Published private(set) var recentSearches: [String] = []
+
+    private let service: DictionaryServicing
+    private var speechSynthesizer = AVSpeechSynthesizer()
+    private var audioPlayer: AVAudioPlayer?
+
+    init(service: DictionaryServicing = DictionaryService()) {
+        self.service = service
+        recentSearches = Self.loadRecentSearches()
+    }
+
+    var hasResult: Bool { entry != nil }
+
+    var canLookUp: Bool {
+        !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading
+    }
+
+    func lookUp() {
+        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            clearResult()
+            errorMessage = DictionaryServiceError.emptyInput.errorDescription
+            return
+        }
+
+        Task {
+            await performLookUp(for: trimmed)
+        }
+    }
+
+    func lookUpRecent(_ word: String) {
+        query = word
+        Task {
+            await performLookUp(for: word)
+        }
+    }
+
+    func pasteWord() {
+        guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
+        let word = clipboard
+            .components(separatedBy: .whitespacesAndNewlines)
+            .first?
+            .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)) ?? ""
+        query = String(word.prefix(Self.maxQueryLength))
+        clearResult()
+    }
+
+    func clearSearch() {
+        query = ""
+        clearResult()
+    }
+
+    func resetResult() {
+        clearResult()
+    }
+
+    func playPronunciation(for entry: DictionaryEntry) {
+        if let audioURL = entry.audioURL {
+            Task {
+                await playRemoteAudio(from: audioURL)
+            }
+            return
+        }
+
+        let utterance = AVSpeechUtterance(string: entry.word)
+        utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
+        utterance.rate = AVSpeechUtteranceDefaultSpeechRate
+        speechSynthesizer.stopSpeaking(at: .immediate)
+        speechSynthesizer.speak(utterance)
+    }
+
+    private func performLookUp(for word: String) async {
+        isLoading = true
+        errorMessage = nil
+        entry = nil
+
+        do {
+            let result = try await service.lookUp(word: word)
+            entry = result
+            query = result.word
+            addRecentSearch(result.word)
+        } catch {
+            errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+        }
+
+        isLoading = false
+    }
+
+    private func clearResult() {
+        entry = nil
+        errorMessage = nil
+    }
+
+    private func addRecentSearch(_ word: String) {
+        var updated = recentSearches.filter { $0.caseInsensitiveCompare(word) != .orderedSame }
+        updated.insert(word, at: 0)
+        recentSearches = Array(updated.prefix(Self.maxRecentSearches))
+        UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
+    }
+
+    private static func loadRecentSearches() -> [String] {
+        UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
+    }
+
+    private func playRemoteAudio(from url: URL) async {
+        do {
+            let (data, _) = try await URLSession.shared.data(from: url)
+            audioPlayer = try AVAudioPlayer(data: data)
+            audioPlayer?.prepareToPlay()
+            audioPlayer?.play()
+        } catch {
+            let utterance = AVSpeechUtterance(string: query)
+            utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
+            speechSynthesizer.stopSpeaking(at: .immediate)
+            speechSynthesizer.speak(utterance)
+        }
+    }
+}

+ 421 - 0
gramora/Views/DictionaryView.swift

@@ -0,0 +1,421 @@
+import SwiftUI
+
+struct DictionaryView: View {
+    @StateObject private var viewModel = DictionaryViewModel()
+
+    private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
+
+    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.05, 24), 44)
+            let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
+
+            ScrollView(.vertical, showsIndicators: false) {
+                VStack(alignment: .leading, spacing: 0) {
+                    header
+                        .padding(.bottom, 28)
+
+                    contentCard
+                        .padding(.bottom, 24)
+
+                    if viewModel.hasResult {
+                        resultCard
+                    }
+                }
+                .frame(maxWidth: 1200, alignment: .topLeading)
+                .frame(maxWidth: .infinity, alignment: .topLeading)
+                .padding(.horizontal, dynamicHorizontalPadding)
+                .padding(.top, dynamicTopPadding)
+                .padding(.bottom, dynamicBottomPadding)
+            }
+        }
+        .onChange(of: viewModel.query) { newValue in
+            if newValue.count > DictionaryViewModel.maxQueryLength {
+                viewModel.query = String(newValue.prefix(DictionaryViewModel.maxQueryLength))
+            }
+            if viewModel.hasResult {
+                viewModel.resetResult()
+            }
+        }
+    }
+
+    private var header: some View {
+        HStack(alignment: .top) {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Dictionary")
+                    .font(.system(size: 30, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                HStack(spacing: 0) {
+                    Text("Find ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("meaning")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(". Learn ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("words")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(". Expand your ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("vocabulary")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(".")
+                        .foregroundStyle(AppTheme.textSecondary)
+                }
+                .font(.system(size: 15))
+            }
+
+            Spacer()
+
+            IconButton {
+                SettingsGearIcon()
+            } action: {}
+            .accessibilityLabel("Settings")
+        }
+    }
+
+    private var contentCard: some View {
+        VStack(alignment: .leading, spacing: 18) {
+            Image("YourContentLogo")
+                .resizable()
+                .aspectRatio(contentMode: .fit)
+                .frame(height: 52, alignment: .topLeading)
+                .frame(height: 46, alignment: .topLeading)
+                .clipped()
+                .accessibilityLabel("Your Content")
+
+            searchField
+
+            if let errorMessage = viewModel.errorMessage {
+                errorBanner(errorMessage)
+            }
+
+            if viewModel.isLoading {
+                loadingState
+            } else if !viewModel.hasResult {
+                emptyState
+            }
+
+            if !viewModel.recentSearches.isEmpty {
+                recentSearchesSection
+            }
+        }
+        .padding(22)
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.border, lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
+    }
+
+    private var searchField: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            Text("Look up a word")
+                .font(.system(size: 13, weight: .semibold))
+                .foregroundStyle(AppTheme.textSecondary)
+
+            HStack(spacing: 10) {
+                HStack(spacing: 10) {
+                    Image(systemName: "magnifyingglass")
+                        .font(.system(size: 14, weight: .medium))
+                        .foregroundStyle(AppTheme.textMuted)
+
+                    TextField("Type a word and press Return...", text: $viewModel.query, onCommit: viewModel.lookUp)
+                        .textFieldStyle(.plain)
+                        .font(.system(size: 16))
+                        .foregroundStyle(AppTheme.textPrimary)
+                }
+                .padding(.horizontal, 16)
+                .padding(.vertical, 14)
+                .background(Color.white)
+                .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                        .stroke(AppTheme.inputBorder, lineWidth: 1)
+                )
+
+                Button(action: viewModel.lookUp) {
+                    HStack(spacing: 8) {
+                        Image(systemName: "book.fill")
+                            .font(.system(size: 13, weight: .semibold))
+
+                        Text("Look Up")
+                            .font(.system(size: 14, weight: .semibold))
+                    }
+                    .foregroundStyle(.white)
+                    .padding(.horizontal, 20)
+                    .padding(.vertical, 14)
+                    .background(viewModel.canLookUp ? AppTheme.primaryGradient : LinearGradient(colors: [AppTheme.border, AppTheme.border], startPoint: .leading, endPoint: .trailing))
+                    .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                }
+                .buttonStyle(.plain)
+                .disabled(!viewModel.canLookUp)
+            }
+
+            HStack(spacing: 8) {
+                ToolbarButton(title: "Paste Word", iconName: "doc.on.clipboard") {
+                    viewModel.pasteWord()
+                }
+
+                Spacer()
+
+                ClearButton {
+                    viewModel.clearSearch()
+                }
+            }
+        }
+    }
+
+    private var emptyState: some View {
+        VStack(spacing: 12) {
+            Image(systemName: "text.book.closed")
+                .font(.system(size: 34))
+                .foregroundStyle(AppTheme.teal.opacity(0.45))
+
+            Text("Search for any English word to see definitions, examples, and synonyms.")
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textSecondary)
+                .multilineTextAlignment(.center)
+                .frame(maxWidth: 420)
+        }
+        .frame(maxWidth: .infinity)
+        .padding(.vertical, 28)
+        .padding(.horizontal, 20)
+        .background(panelBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
+        )
+    }
+
+    private var loadingState: some View {
+        HStack(spacing: 12) {
+            ProgressView()
+                .controlSize(.small)
+
+            Text("Looking up definition...")
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textSecondary)
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(18)
+        .background(panelBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+    }
+
+    private func errorBanner(_ message: String) -> some View {
+        HStack(spacing: 10) {
+            Image(systemName: "exclamationmark.triangle.fill")
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.clearForeground)
+
+            Text(message)
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.clearForeground)
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.horizontal, 16)
+        .padding(.vertical, 12)
+        .background(AppTheme.clearBackground)
+        .clipShape(RoundedRectangle(cornerRadius: 10))
+        .overlay(
+            RoundedRectangle(cornerRadius: 10)
+                .stroke(AppTheme.clearBorder, lineWidth: 1)
+        )
+    }
+
+    private var recentSearchesSection: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            Text("Recent Searches")
+                .font(.system(size: 13, weight: .semibold))
+                .foregroundStyle(AppTheme.textSecondary)
+
+            LazyVGrid(
+                columns: [GridItem(.adaptive(minimum: 72), spacing: 8)],
+                alignment: .leading,
+                spacing: 8
+            ) {
+                ForEach(viewModel.recentSearches, id: \.self) { word in
+                    Button {
+                        viewModel.lookUpRecent(word)
+                    } label: {
+                        Text(word)
+                            .font(.system(size: 13, weight: .medium))
+                            .foregroundStyle(AppTheme.teal)
+                            .padding(.horizontal, 12)
+                            .padding(.vertical, 7)
+                            .background(AppTheme.tealLight)
+                            .clipShape(Capsule())
+                    }
+                    .buttonStyle(.plain)
+                }
+            }
+        }
+    }
+
+    private var resultCard: some View {
+        Group {
+            if let entry = viewModel.entry {
+                VStack(alignment: .leading, spacing: 20) {
+                    wordHeader(entry)
+
+                    ForEach(entry.meanings) { meaning in
+                        meaningSection(meaning)
+                    }
+                }
+                .padding(22)
+                .background(AppTheme.cardBackground)
+                .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                        .stroke(AppTheme.border, lineWidth: 1)
+                )
+                .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
+            }
+        }
+    }
+
+    private func wordHeader(_ entry: DictionaryEntry) -> some View {
+        HStack(alignment: .top, spacing: 16) {
+            VStack(alignment: .leading, spacing: 6) {
+                Text(entry.word)
+                    .font(.system(size: 34, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                if let phonetic = entry.phonetic {
+                    Text(phonetic)
+                        .font(.system(size: 16))
+                        .foregroundStyle(AppTheme.textSecondary)
+                }
+            }
+
+            Spacer()
+
+            Button {
+                viewModel.playPronunciation(for: entry)
+            } label: {
+                HStack(spacing: 8) {
+                    Image(systemName: "speaker.wave.2.fill")
+                        .font(.system(size: 13, weight: .semibold))
+
+                    Text("Listen")
+                        .font(.system(size: 13, weight: .semibold))
+                }
+                .foregroundStyle(AppTheme.teal)
+                .padding(.horizontal, 14)
+                .padding(.vertical, 9)
+                .background(AppTheme.tealLight)
+                .clipShape(RoundedRectangle(cornerRadius: 10))
+            }
+            .buttonStyle(.plain)
+            .accessibilityLabel("Play pronunciation")
+        }
+    }
+
+    private func meaningSection(_ meaning: DictionaryMeaning) -> some View {
+        VStack(alignment: .leading, spacing: 14) {
+            PartOfSpeechBadge(title: meaning.partOfSpeech)
+
+            VStack(alignment: .leading, spacing: 12) {
+                ForEach(Array(meaning.definitions.enumerated()), id: \.element.id) { index, definition in
+                    definitionRow(number: index + 1, definition: definition)
+                }
+            }
+
+            if !meaning.synonyms.isEmpty {
+                relatedWordsSection(title: "Synonyms", words: meaning.synonyms)
+            }
+
+            if !meaning.antonyms.isEmpty {
+                relatedWordsSection(title: "Antonyms", words: meaning.antonyms)
+            }
+        }
+        .padding(18)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(panelBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
+        )
+    }
+
+    private func definitionRow(number: Int, definition: DictionaryDefinition) -> some View {
+        VStack(alignment: .leading, spacing: 6) {
+            HStack(alignment: .top, spacing: 10) {
+                Text("\(number).")
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(AppTheme.teal)
+                    .frame(width: 20, alignment: .leading)
+
+                Text(definition.text)
+                    .font(.system(size: 14))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .fixedSize(horizontal: false, vertical: true)
+            }
+
+            if let example = definition.example {
+                Text("\"\(example)\"")
+                    .font(.system(size: 13))
+                    .italic()
+                    .foregroundStyle(AppTheme.textMuted)
+                    .padding(.leading, 30)
+            }
+        }
+    }
+
+    private func relatedWordsSection(title: String, words: [String]) -> some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Text(title)
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textSecondary)
+
+            LazyVGrid(
+                columns: [GridItem(.adaptive(minimum: 72), spacing: 8)],
+                alignment: .leading,
+                spacing: 8
+            ) {
+                ForEach(words, id: \.self) { word in
+                    Button {
+                        viewModel.lookUpRecent(word)
+                    } label: {
+                        Text(word)
+                            .font(.system(size: 12, weight: .medium))
+                            .foregroundStyle(AppTheme.textSecondary)
+                            .padding(.horizontal, 10)
+                            .padding(.vertical, 6)
+                            .background(Color.white)
+                            .clipShape(Capsule())
+                            .overlay(
+                                Capsule()
+                                    .stroke(AppTheme.border.opacity(0.7), lineWidth: 1)
+                            )
+                    }
+                    .buttonStyle(.plain)
+                }
+            }
+        }
+        .padding(.top, 4)
+    }
+}
+
+private struct PartOfSpeechBadge: View {
+    let title: String
+
+    var body: some View {
+        Text(title.capitalized)
+            .font(.system(size: 12, weight: .semibold))
+            .foregroundStyle(AppTheme.teal)
+            .padding(.horizontal, 12)
+            .padding(.vertical, 6)
+            .background(AppTheme.tealLight)
+            .clipShape(Capsule())
+    }
+}

+ 2 - 0
gramora/Views/MainView.swift

@@ -66,6 +66,8 @@ struct MainView: View {
             EmailWriterView()
         case .spellingChecker:
             SpellCheckerView()
+        case .dictionary:
+            DictionaryView()
         default:
             PlaceholderView(destination: viewModel.selectedDestination)
         }