import SwiftUI import SwiftData enum VocabularySortMode: String { case wordAsc = "wordAsc" case wordDesc = "wordDesc" } struct VocabularyView: View { @Query private var vocabularyWords: [VocabularyWord] @Environment(\.modelContext) private var modelContext @ObservedObject var whisperPrompt: WhisperPrompt @State private var newWord = "" @State private var showAlert = false @State private var alertMessage = "" @State private var sortMode: VocabularySortMode = .wordAsc init(whisperPrompt: WhisperPrompt) { self.whisperPrompt = whisperPrompt if let savedSort = UserDefaults.standard.string(forKey: "vocabularySortMode"), let mode = VocabularySortMode(rawValue: savedSort) { _sortMode = State(initialValue: mode) } } private var sortedItems: [VocabularyWord] { switch sortMode { case .wordAsc: return vocabularyWords.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedAscending } case .wordDesc: return vocabularyWords.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedDescending } } } private func toggleSort() { sortMode = (sortMode == .wordAsc) ? .wordDesc : .wordAsc UserDefaults.standard.set(sortMode.rawValue, forKey: "vocabularySortMode") } private var shouldShowAddButton: Bool { !newWord.isEmpty } var body: some View { VStack(alignment: .leading, spacing: 20) { GroupBox { Label { Text("Add words to help VoiceInk recognize them properly. (Requires AI enhancement)") .font(.system(size: 12)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: "info.circle.fill") .foregroundColor(.blue) } } HStack(spacing: 8) { TextField("Add word to vocabulary", text: $newWord) .textFieldStyle(.roundedBorder) .font(.system(size: 13)) .onSubmit { addWords() } if shouldShowAddButton { Button(action: addWords) { Image(systemName: "plus.circle.fill") .symbolRenderingMode(.hierarchical) .foregroundStyle(.blue) .font(.system(size: 16, weight: .semibold)) } .buttonStyle(.borderless) .disabled(newWord.isEmpty) .help("Add word") } } .animation(.easeInOut(duration: 0.2), value: shouldShowAddButton) if !vocabularyWords.isEmpty { VStack(alignment: .leading, spacing: 12) { Button(action: toggleSort) { HStack(spacing: 4) { Text("Vocabulary Words (\(vocabularyWords.count))") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) Image(systemName: sortMode == .wordAsc ? "chevron.up" : "chevron.down") .font(.caption) .foregroundColor(.accentColor) } } .buttonStyle(.plain) .help("Sort alphabetically") ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 240, maximum: .infinity), spacing: 12)], alignment: .leading, spacing: 12) { ForEach(sortedItems) { item in VocabularyWordView(item: item) { removeWord(item) } } } .padding(.vertical, 4) } .frame(maxHeight: 200) } .padding(.top, 4) } } .padding() .alert("Vocabulary", isPresented: $showAlert) { Button("OK", role: .cancel) {} } message: { Text(alertMessage) } } private func addWords() { let input = newWord.trimmingCharacters(in: .whitespacesAndNewlines) guard !input.isEmpty else { return } let parts = input .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard !parts.isEmpty else { return } if parts.count == 1, let word = parts.first { if vocabularyWords.contains(where: { $0.word.lowercased() == word.lowercased() }) { alertMessage = "'\(word)' is already in the vocabulary" showAlert = true return } addWord(word) newWord = "" return } for word in parts { let lower = word.lowercased() if !vocabularyWords.contains(where: { $0.word.lowercased() == lower }) { addWord(word) } } newWord = "" } private func addWord(_ word: String) { let normalizedWord = word.trimmingCharacters(in: .whitespacesAndNewlines) guard !vocabularyWords.contains(where: { $0.word.lowercased() == normalizedWord.lowercased() }) else { return } let newWord = VocabularyWord(word: normalizedWord) modelContext.insert(newWord) do { try modelContext.save() } catch { // Rollback the insert to maintain UI consistency modelContext.delete(newWord) alertMessage = "Failed to add word: \(error.localizedDescription)" showAlert = true } } private func removeWord(_ word: VocabularyWord) { modelContext.delete(word) do { try modelContext.save() } catch { // Rollback the delete to restore UI consistency modelContext.rollback() alertMessage = "Failed to remove word: \(error.localizedDescription)" showAlert = true } } } struct VocabularyWordView: View { let item: VocabularyWord let onDelete: () -> Void @State private var isDeleteHovered = false var body: some View { HStack(spacing: 6) { Text(item.word) .font(.system(size: 13)) .lineLimit(1) .foregroundColor(.primary) Spacer(minLength: 8) Button(action: onDelete) { Image(systemName: "xmark.circle.fill") .symbolRenderingMode(.hierarchical) .foregroundStyle(isDeleteHovered ? .red : .secondary) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.borderless) .help("Remove word") .onHover { hover in withAnimation(.easeInOut(duration: 0.2)) { isDeleteHovered = hover } } } .padding(.horizontal, 8) .padding(.vertical, 6) .background { RoundedRectangle(cornerRadius: 6) .fill(Color(.windowBackgroundColor).opacity(0.4)) } .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) } .shadow(color: Color.black.opacity(0.05), radius: 2, y: 1) } }