vOOice/VoiceInk/Views/EnhancementSettingsView.swift
2026-01-05 14:48:22 +05:45

284 lines
11 KiB
Swift

import SwiftUI
import UniformTypeIdentifiers
struct EnhancementSettingsView: View {
@EnvironmentObject private var enhancementService: AIEnhancementService
@State private var isEditingPrompt = false
@State private var isShortcutsExpanded = false
@State private var selectedPromptForEdit: CustomPrompt?
private var isPanelOpen: Bool {
isEditingPrompt || selectedPromptForEdit != nil
}
private func closePanel() {
withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) {
isEditingPrompt = false
selectedPromptForEdit = nil
}
}
var body: some View {
ZStack(alignment: .topLeading) {
Form {
Section {
Toggle(isOn: $enhancementService.isEnhancementEnabled) {
HStack(spacing: 4) {
Text("Enable Enhancement")
InfoTip(
title: "AI Enhancement",
message: "AI enhancement lets you pass the transcribed audio through LLMs to post-process using different prompts suitable for different use cases like e-mails, summary, writing, etc.",
learnMoreURL: "https://tryvoiceink.com/docs/enhancements-configuring-models"
)
}
}
.toggleStyle(.switch)
HStack(spacing: 24) {
Toggle(isOn: $enhancementService.useClipboardContext) {
HStack(spacing: 4) {
Text("Clipboard Context")
InfoTip(
title: "Clipboard Context",
message: "Use text from clipboard to understand the context"
)
}
}
.toggleStyle(.switch)
Toggle(isOn: $enhancementService.useScreenCaptureContext) {
HStack(spacing: 4) {
Text("Screen Context")
InfoTip(
title: "Context Awareness",
message: "Learn what is on the screen to understand the context"
)
}
}
.toggleStyle(.switch)
}
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
} header: {
Text("General")
}
APIKeyManagementView()
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
Section {
ReorderablePromptGrid(
selectedPromptId: enhancementService.selectedPromptId,
onPromptSelected: { prompt in
enhancementService.setActivePrompt(prompt)
},
onEditPrompt: { prompt in
withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) {
selectedPromptForEdit = prompt
}
},
onDeletePrompt: { prompt in
enhancementService.deletePrompt(prompt)
}
)
.padding(.vertical, 8)
} header: {
HStack {
Text("Enhancement Prompts")
Spacer()
Button {
withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) {
isEditingPrompt = true
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.system(size: 18))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Add new prompt")
}
}
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
Section {
DisclosureGroup(isExpanded: $isShortcutsExpanded) {
EnhancementShortcutsView()
.padding(.vertical, 8)
} label: {
HStack {
Text("Shortcuts")
.font(.headline)
.foregroundColor(.primary)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation {
isShortcutsExpanded.toggle()
}
}
}
}
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(Color(NSColor.controlBackgroundColor))
.disabled(isPanelOpen)
.blur(radius: isPanelOpen ? 2 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.9), value: isPanelOpen)
if isPanelOpen {
Color.black.opacity(0.2)
.ignoresSafeArea()
.onTapGesture {
closePanel()
}
.transition(.opacity)
.zIndex(1)
}
if isPanelOpen {
HStack(spacing: 0) {
Spacer()
Group {
if let prompt = selectedPromptForEdit {
PromptEditorView(mode: .edit(prompt)) {
closePanel()
}
} else if isEditingPrompt {
PromptEditorView(mode: .add) {
closePanel()
}
}
}
.frame(width: 450)
.frame(maxHeight: .infinity)
.background(
Color(NSColor.windowBackgroundColor)
)
.overlay(
Divider(), alignment: .leading
)
.shadow(color: .black.opacity(0.15), radius: 12, x: -4, y: 0)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
.ignoresSafeArea()
.zIndex(2)
}
}
.frame(minWidth: 500, minHeight: 400)
}
}
// MARK: - Reorderable Grid
private struct ReorderablePromptGrid: View {
@EnvironmentObject private var enhancementService: AIEnhancementService
let selectedPromptId: UUID?
let onPromptSelected: (CustomPrompt) -> Void
let onEditPrompt: ((CustomPrompt) -> Void)?
let onDeletePrompt: ((CustomPrompt) -> Void)?
@State private var draggingItem: CustomPrompt?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if enhancementService.customPrompts.isEmpty {
Text("No prompts available")
.foregroundColor(.secondary)
.font(.caption)
} else {
let columns = [
GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 36)
]
LazyVGrid(columns: columns, spacing: 16) {
ForEach(enhancementService.customPrompts) { prompt in
prompt.promptIcon(
isSelected: selectedPromptId == prompt.id,
onTap: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
onPromptSelected(prompt)
}
},
onEdit: onEditPrompt,
onDelete: onDeletePrompt
)
.opacity(draggingItem?.id == prompt.id ? 0.3 : 1.0)
.scaleEffect(draggingItem?.id == prompt.id ? 1.05 : 1.0)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(
draggingItem != nil && draggingItem?.id != prompt.id
? Color.accentColor.opacity(0.25)
: Color.clear,
lineWidth: 1
)
)
.animation(.easeInOut(duration: 0.15), value: draggingItem?.id == prompt.id)
.onDrag {
draggingItem = prompt
return NSItemProvider(object: prompt.id.uuidString as NSString)
}
.onDrop(
of: [UTType.text],
delegate: PromptDropDelegate(
item: prompt,
prompts: $enhancementService.customPrompts,
draggingItem: $draggingItem
)
)
}
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
HStack {
Image(systemName: "info.circle")
.font(.caption)
.foregroundColor(.secondary)
Text("Double-click to edit • Right-click for more options")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.top, 8)
.padding(.horizontal, 16)
}
}
}
}
// MARK: - Drop Delegate
private struct PromptDropDelegate: DropDelegate {
let item: CustomPrompt
@Binding var prompts: [CustomPrompt]
@Binding var draggingItem: CustomPrompt?
func dropEntered(info: DropInfo) {
guard let draggingItem = draggingItem, draggingItem != item else { return }
guard let fromIndex = prompts.firstIndex(of: draggingItem),
let toIndex = prompts.firstIndex(of: item) else { return }
if prompts[toIndex].id != draggingItem.id {
withAnimation(.easeInOut(duration: 0.12)) {
let from = fromIndex
let to = toIndex
prompts.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
draggingItem = nil
return true
}
}