vOOice/VoiceInk/Views/Dictionary/WordReplacementView.swift

410 lines
16 KiB
Swift

import SwiftUI
extension String: Identifiable {
public var id: String { self }
}
class WordReplacementManager: ObservableObject {
@Published var replacements: [String: String] {
didSet {
UserDefaults.standard.set(replacements, forKey: "wordReplacements")
}
}
@Published var isEnabled: Bool {
didSet {
UserDefaults.standard.set(isEnabled, forKey: "IsWordReplacementEnabled")
}
}
init() {
self.replacements = UserDefaults.standard.dictionary(forKey: "wordReplacements") as? [String: String] ?? [:]
self.isEnabled = UserDefaults.standard.bool(forKey: "IsWordReplacementEnabled")
}
func addReplacement(original: String, replacement: String) {
// Preserve comma-separated originals as a single entry
let trimmed = original.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
replacements[trimmed] = replacement
}
func removeReplacement(original: String) {
replacements.removeValue(forKey: original)
}
func updateReplacement(oldOriginal: String, newOriginal: String, newReplacement: String) {
// Replace old key with the new comma-preserved key
replacements.removeValue(forKey: oldOriginal)
let trimmed = newOriginal.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
replacements[trimmed] = newReplacement
}
}
struct WordReplacementView: View {
@StateObject private var manager = WordReplacementManager()
@State private var showAddReplacementModal = false
@State private var showAlert = false
@State private var editingOriginal: String? = nil
@State private var alertMessage = ""
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// Info Section with Toggle
GroupBox {
HStack {
Label {
Text("Define word replacements to automatically replace specific words or phrases")
.font(.system(size: 12))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(alignment: .leading)
} icon: {
Image(systemName: "info.circle.fill")
.foregroundColor(.blue)
}
Spacer()
Toggle("Enable", isOn: $manager.isEnabled)
.toggleStyle(.switch)
.labelsHidden()
.help("Enable automatic word replacement after transcription")
}
}
VStack(spacing: 0) {
// Header with action button
HStack {
Text("Word Replacements")
.font(.headline)
Spacer()
Button(action: { showAddReplacementModal = true }) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
.help("Add new replacement")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color(.controlBackgroundColor))
Divider()
// Content
if manager.replacements.isEmpty {
EmptyStateView(showAddModal: $showAddReplacementModal)
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(manager.replacements.keys.sorted()), id: \.self) { original in
ReplacementRow(
original: original,
replacement: manager.replacements[original] ?? "",
onDelete: { manager.removeReplacement(original: original) },
onEdit: { editingOriginal = original }
)
if original != manager.replacements.keys.sorted().last {
Divider()
.padding(.leading, 32)
}
}
}
.background(Color(.controlBackgroundColor))
}
}
}
}
.padding()
.sheet(isPresented: $showAddReplacementModal) {
AddReplacementSheet(manager: manager)
}
// Edit existing replacement
.sheet(item: $editingOriginal) { original in
EditReplacementSheet(manager: manager, originalKey: original)
}
}
}
struct EmptyStateView: View {
@Binding var showAddModal: Bool
var body: some View {
VStack(spacing: 12) {
Image(systemName: "text.word.spacing")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("No Replacements")
.font(.headline)
Text("Add word replacements to automatically replace text.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 250)
Button("Add Replacement") {
showAddModal = true
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
.padding(.top, 8)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct AddReplacementSheet: View {
@ObservedObject var manager: WordReplacementManager
@Environment(\.dismiss) private var dismiss
@State private var originalWord = ""
@State private var replacementWord = ""
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Button("Cancel", role: .cancel) {
dismiss()
}
.buttonStyle(.borderless)
.keyboardShortcut(.escape, modifiers: [])
Spacer()
Text("Add Word Replacement")
.font(.headline)
Spacer()
Button("Add") {
addReplacement()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(originalWord.isEmpty || replacementWord.isEmpty)
.keyboardShortcut(.return, modifiers: [])
}
.padding(.horizontal)
.padding(.vertical, 12)
.background(CardBackground(isSelected: false))
Divider()
ScrollView {
VStack(spacing: 20) {
// Description
Text("Define a word or phrase to be automatically replaced during AI enhancement.")
.font(.subheadline)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.top, 8)
// Form Content
VStack(spacing: 16) {
// Original Text Section
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Original Text")
.font(.headline)
.foregroundColor(.primary)
Text("Required")
.font(.caption)
.foregroundColor(.secondary)
}
TextField("Enter word or phrase to replace (use commas for multiple)", text: $originalWord)
.textFieldStyle(.roundedBorder)
.font(.body)
Text("Separate multiple originals with commas, e.g. Voicing, Voice ink, Voiceing")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal)
// Replacement Text Section
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Replacement Text")
.font(.headline)
.foregroundColor(.primary)
Text("Required")
.font(.caption)
.foregroundColor(.secondary)
}
TextEditor(text: $replacementWord)
.font(.body)
.frame(height: 100)
.padding(8)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color(.separatorColor), lineWidth: 1)
)
}
.padding(.horizontal)
}
// Example Section
VStack(alignment: .leading, spacing: 8) {
Text("Examples")
.font(.subheadline)
.foregroundColor(.secondary)
// Single original -> replacement
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Original:")
.font(.caption)
.foregroundColor(.secondary)
Text("my website link")
.font(.callout)
}
Image(systemName: "arrow.right")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Replacement:")
.font(.caption)
.foregroundColor(.secondary)
Text("https://tryvoiceink.com")
.font(.callout)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.textBackgroundColor))
.cornerRadius(8)
// Comma-separated originals -> single replacement
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Original:")
.font(.caption)
.foregroundColor(.secondary)
Text("Voicing, Voice ink, Voiceing")
.font(.callout)
}
Image(systemName: "arrow.right")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Replacement:")
.font(.caption)
.foregroundColor(.secondary)
Text("VoiceInk")
.font(.callout)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.textBackgroundColor))
.cornerRadius(8)
}
.padding(.horizontal)
.padding(.top, 8)
}
.padding(.vertical)
}
}
.frame(width: 460, height: 520)
}
private func addReplacement() {
let original = originalWord
let replacement = replacementWord
// Validate that at least one non-empty token exists
let tokens = original
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !tokens.isEmpty && !replacement.isEmpty else { return }
manager.addReplacement(original: original, replacement: replacement)
dismiss()
}
}
struct ReplacementRow: View {
let original: String
let replacement: String
let onDelete: () -> Void
let onEdit: () -> Void
var body: some View {
HStack(spacing: 16) {
// Original Text Container
HStack {
Text(original)
.font(.body)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
}
.frame(maxWidth: .infinity)
// Arrow
Image(systemName: "arrow.right")
.foregroundColor(.secondary)
.font(.system(size: 12))
// Replacement Text Container
HStack {
Text(replacement)
.font(.body)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
}
.frame(maxWidth: .infinity)
// Edit Button
Button(action: onEdit) {
Image(systemName: "pencil.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundColor(.accentColor)
.font(.system(size: 16))
}
.buttonStyle(.borderless)
.help("Edit replacement")
// Delete Button
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.red)
.font(.system(size: 16))
}
.buttonStyle(.borderless)
.help("Remove replacement")
}
.padding(.horizontal)
.padding(.vertical, 8)
.contentShape(Rectangle())
.background(Color(.controlBackgroundColor))
}
}