261 lines
8.8 KiB
Swift
261 lines
8.8 KiB
Swift
import SwiftUI
|
|
|
|
struct VocabularyWord: Identifiable, Hashable, Codable {
|
|
var word: String
|
|
|
|
var id: String { word }
|
|
|
|
init(word: String) {
|
|
self.word = word
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, word, dateAdded
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
word = try container.decode(String.self, forKey: .word)
|
|
_ = try? container.decodeIfPresent(UUID.self, forKey: .id)
|
|
_ = try? container.decodeIfPresent(Date.self, forKey: .dateAdded)
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(word, forKey: .word)
|
|
}
|
|
}
|
|
|
|
enum VocabularySortMode: String {
|
|
case wordAsc = "wordAsc"
|
|
case wordDesc = "wordDesc"
|
|
}
|
|
|
|
class VocabularyManager: ObservableObject {
|
|
@Published var items: [VocabularyWord] = []
|
|
private let saveKey = "CustomVocabularyItems"
|
|
private let whisperPrompt: WhisperPrompt
|
|
|
|
init(whisperPrompt: WhisperPrompt) {
|
|
self.whisperPrompt = whisperPrompt
|
|
loadItems()
|
|
}
|
|
|
|
private func loadItems() {
|
|
guard let data = UserDefaults.standard.data(forKey: saveKey) else { return }
|
|
|
|
if let savedItems = try? JSONDecoder().decode([VocabularyWord].self, from: data) {
|
|
items = savedItems
|
|
}
|
|
}
|
|
|
|
private func saveItems() {
|
|
if let encoded = try? JSONEncoder().encode(items) {
|
|
UserDefaults.standard.set(encoded, forKey: saveKey)
|
|
}
|
|
}
|
|
|
|
func addWord(_ word: String) {
|
|
let normalizedWord = word.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !items.contains(where: { $0.word.lowercased() == normalizedWord.lowercased() }) else {
|
|
return
|
|
}
|
|
|
|
let newItem = VocabularyWord(word: normalizedWord)
|
|
items.insert(newItem, at: 0)
|
|
saveItems()
|
|
}
|
|
|
|
func removeWord(_ word: String) {
|
|
items.removeAll(where: { $0.word == word })
|
|
saveItems()
|
|
}
|
|
|
|
var allWords: [String] {
|
|
items.map { $0.word }
|
|
}
|
|
}
|
|
|
|
struct VocabularyView: View {
|
|
@StateObject private var vocabularyManager: VocabularyManager
|
|
@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
|
|
_vocabularyManager = StateObject(wrappedValue: VocabularyManager(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 vocabularyManager.items.sorted { $0.word.localizedCaseInsensitiveCompare($1.word) == .orderedAscending }
|
|
case .wordDesc:
|
|
return vocabularyManager.items.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 !vocabularyManager.items.isEmpty {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Button(action: toggleSort) {
|
|
HStack(spacing: 4) {
|
|
Text("Vocabulary Words (\(vocabularyManager.items.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) {
|
|
vocabularyManager.removeWord(item.word)
|
|
}
|
|
}
|
|
}
|
|
.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 vocabularyManager.items.contains(where: { $0.word.lowercased() == word.lowercased() }) {
|
|
alertMessage = "'\(word)' is already in the vocabulary"
|
|
showAlert = true
|
|
return
|
|
}
|
|
vocabularyManager.addWord(word)
|
|
newWord = ""
|
|
return
|
|
}
|
|
|
|
for word in parts {
|
|
let lower = word.lowercased()
|
|
if !vocabularyManager.items.contains(where: { $0.word.lowercased() == lower }) {
|
|
vocabularyManager.addWord(word)
|
|
}
|
|
}
|
|
newWord = ""
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|