Improve API key management UI and enhance provider integration layout

- Reorganize provider connection status display
- Add visual separators between sections for better clarity
- Improve OpenRouter model picker with inline refresh button
- Enhance API key input styling with rounded borders
- Optimize Ollama configuration layout for consistency
- Refine button positioning and spacing throughout
This commit is contained in:
Beingpax 2026-01-05 11:15:11 +05:45
parent f712652278
commit ae387ad351
3 changed files with 309 additions and 536 deletions

View File

@ -13,74 +13,95 @@ struct APIKeyManagementView: View {
@State private var isEditingURL = false @State private var isEditingURL = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { Section("AI Provider Integration") {
// Provider Selection
HStack { HStack {
Picker("AI Provider", selection: $aiService.selectedProvider) { Picker("Provider", selection: $aiService.selectedProvider) {
ForEach(AIProvider.allCases.filter { $0 != .elevenLabs && $0 != .deepgram && $0 != .soniox }, id: \.self) { provider in ForEach(AIProvider.allCases.filter { $0 != .elevenLabs && $0 != .deepgram && $0 != .soniox }, id: \.self) { provider in
Text(provider.rawValue).tag(provider) Text(provider.rawValue).tag(provider)
} }
} }
.pickerStyle(.automatic)
.tint(.blue)
Spacer() // Show connected status for all providers
if aiService.isAPIKeyValid && aiService.selectedProvider != .ollama { if aiService.isAPIKeyValid && aiService.selectedProvider != .ollama {
HStack(spacing: 6) { Spacer()
Circle()
.fill(Color.green)
.frame(width: 8, height: 8)
Text("Connected")
.font(.subheadline)
.foregroundColor(.secondary)
} else if aiService.selectedProvider == .ollama {
Spacer()
if isCheckingOllama {
ProgressView()
.controlSize(.small)
} else if !ollamaModels.isEmpty {
Circle() Circle()
.fill(Color.green) .fill(Color.green)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
Text("Connected to") Text("Connected")
.font(.caption) .font(.subheadline)
Text(aiService.selectedProvider.rawValue) .foregroundColor(.secondary)
.font(.caption.bold()) } else {
Circle()
.fill(Color.red)
.frame(width: 8, height: 8)
Text("Disconnected")
.font(.subheadline)
.foregroundColor(.secondary)
} }
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.1))
.foregroundColor(.secondary)
.cornerRadius(6)
} }
} }
.onChange(of: aiService.selectedProvider) { oldValue, newValue in .onChange(of: aiService.selectedProvider) { oldValue, newValue in
if aiService.selectedProvider == .ollama { if aiService.selectedProvider == .ollama {
checkOllamaConnection() checkOllamaConnection()
} }
} }
// Model Selection VStack(alignment: .leading, spacing: 12) {
if aiService.selectedProvider == .openRouter { // Model Selection
HStack { if aiService.selectedProvider == .openRouter {
if aiService.availableModels.isEmpty { if aiService.availableModels.isEmpty {
Text("No models loaded") HStack {
.foregroundColor(.secondary) Text("No models loaded")
.foregroundColor(.secondary)
Spacer()
Button(action: {
Task {
await aiService.fetchOpenRouterModels()
}
}) {
Label("Refresh", systemImage: "arrow.clockwise")
}
}
} else { } else {
Picker("Model", selection: Binding( HStack {
get: { aiService.currentModel }, Picker("Model", selection: Binding(
set: { aiService.selectModel($0) } get: { aiService.currentModel },
)) { set: { aiService.selectModel($0) }
ForEach(aiService.availableModels, id: \.self) { model in )) {
Text(model).tag(model) ForEach(aiService.availableModels, id: \.self) { model in
Text(model).tag(model)
}
}
Spacer()
Button(action: {
Task {
await aiService.fetchOpenRouterModels()
}
}) {
Label("Refresh", systemImage: "arrow.clockwise")
} }
} }
} }
} else if !aiService.availableModels.isEmpty &&
aiService.selectedProvider != .ollama &&
Button(action: { aiService.selectedProvider != .custom {
Task {
await aiService.fetchOpenRouterModels()
}
}) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh models")
}
} else if !aiService.availableModels.isEmpty &&
aiService.selectedProvider != .ollama &&
aiService.selectedProvider != .custom {
HStack {
Picker("Model", selection: Binding( Picker("Model", selection: Binding(
get: { aiService.currentModel }, get: { aiService.currentModel },
set: { aiService.selectModel($0) } set: { aiService.selectModel($0) }
@ -90,62 +111,27 @@ struct APIKeyManagementView: View {
} }
} }
} }
}
if aiService.selectedProvider == .ollama { Divider()
VStack(alignment: .leading, spacing: 16) {
// Header with status
HStack {
Label("Ollama Configuration", systemImage: "server.rack")
.font(.headline)
Spacer() if aiService.selectedProvider == .ollama {
// Ollama Configuration inline
HStack(spacing: 6) { if isEditingURL {
Circle() HStack {
.fill(isCheckingOllama ? Color.orange : (ollamaModels.isEmpty ? Color.red : Color.green))
.frame(width: 8, height: 8)
Text(isCheckingOllama ? "Checking..." : (ollamaModels.isEmpty ? "Disconnected" : "Connected"))
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.1))
.cornerRadius(6)
}
// Server URL
HStack {
Label("Server URL", systemImage: "link")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
if isEditingURL {
TextField("Base URL", text: $ollamaBaseURL) TextField("Base URL", text: $ollamaBaseURL)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(.roundedBorder)
.frame(maxWidth: 200)
Button("Save") { Button("Save") {
aiService.updateOllamaBaseURL(ollamaBaseURL) aiService.updateOllamaBaseURL(ollamaBaseURL)
checkOllamaConnection() checkOllamaConnection()
isEditingURL = false isEditingURL = false
} }
.buttonStyle(.bordered) }
.controlSize(.small) } else {
} else { HStack {
Text(ollamaBaseURL) Text("Server: \(ollamaBaseURL)")
.font(.system(.body, design: .monospaced)) Spacer()
.foregroundColor(.primary) Button("Edit") { isEditingURL = true }
Button(action: { isEditingURL = true }) {
Image(systemName: "pencil")
}
.buttonStyle(.borderless)
.controlSize(.small)
Button(action: { Button(action: {
ollamaBaseURL = "http://localhost:11434" ollamaBaseURL = "http://localhost:11434"
aiService.updateOllamaBaseURL(ollamaBaseURL) aiService.updateOllamaBaseURL(ollamaBaseURL)
@ -153,190 +139,90 @@ struct APIKeyManagementView: View {
}) { }) {
Image(systemName: "arrow.counterclockwise") Image(systemName: "arrow.counterclockwise")
} }
.buttonStyle(.borderless) .help("Reset to default")
.foregroundColor(.secondary)
.controlSize(.small)
} }
} }
// Model selection and refresh if !ollamaModels.isEmpty {
HStack { Picker("Model", selection: $selectedOllamaModel) {
Label("Model", systemImage: "cpu") ForEach(ollamaModels) { model in
.font(.subheadline) Text(model.name).tag(model.name)
.foregroundColor(.secondary)
Spacer()
if ollamaModels.isEmpty {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("No models available")
.foregroundColor(.secondary)
.italic()
} }
} else {
Picker("", selection: $selectedOllamaModel) {
ForEach(ollamaModels) { model in
Text(model.name).tag(model.name)
}
}
.onChange(of: selectedOllamaModel) { oldValue, newValue in
aiService.updateSelectedOllamaModel(newValue)
}
.labelsHidden()
.frame(maxWidth: 150)
} }
.onChange(of: selectedOllamaModel) { oldValue, newValue in
Button(action: { checkOllamaConnection() }) { aiService.updateSelectedOllamaModel(newValue)
Label(isCheckingOllama ? "Refreshing..." : "Refresh", systemImage: isCheckingOllama ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
.font(.caption)
}
.disabled(isCheckingOllama)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding()
.background(Color.secondary.opacity(0.03))
.cornerRadius(12)
} else if aiService.selectedProvider == .custom {
VStack(alignment: .leading, spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text("Custom Provider Configuration")
.font(.headline)
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.caption)
Text("Requires OpenAI-compatible API endpoint")
.font(.caption)
.foregroundColor(.secondary)
} }
} }
// Configuration Fields Button("Refresh Connection") {
VStack(alignment: .leading, spacing: 8) { checkOllamaConnection()
if !aiService.isAPIKeyValid {
TextField("API Endpoint URL (e.g., https://api.example.com/v1/chat/completions)", text: $aiService.customBaseURL)
.textFieldStyle(.roundedBorder)
TextField("Model Name (e.g., gpt-4o-mini, claude-3-5-sonnet-20240620)", text: $aiService.customModel)
.textFieldStyle(.roundedBorder)
} else {
VStack(alignment: .leading, spacing: 8) {
Text("API Endpoint URL")
.font(.subheadline)
.foregroundColor(.secondary)
Text(aiService.customBaseURL)
.font(.system(.body, design: .monospaced))
Text("Model")
.font(.subheadline)
.foregroundColor(.secondary)
Text(aiService.customModel)
.font(.system(.body, design: .monospaced))
}
}
if aiService.isAPIKeyValid {
Text("API Key")
.font(.subheadline)
.foregroundColor(.secondary)
HStack {
Text(String(repeating: "", count: 40))
.font(.system(.body, design: .monospaced))
Spacer()
Button(action: {
aiService.clearAPIKey()
}) {
Label("Remove Key", systemImage: "trash")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
} else {
Text("Enter your API Key")
.font(.subheadline)
.foregroundColor(.secondary)
SecureField("API Key", text: $apiKey)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
HStack {
Button(action: {
isVerifying = true
aiService.saveAPIKey(apiKey) { success, errorMessage in
isVerifying = false
if !success {
alertMessage = errorMessage ?? "Verification failed"
showAlert = true
}
apiKey = ""
}
}) {
HStack {
if isVerifying {
ProgressView()
.scaleEffect(0.5)
.frame(width: 16, height: 16)
} else {
Image(systemName: "checkmark.circle.fill")
}
Text("Verify and Save")
}
}
.disabled(aiService.customBaseURL.isEmpty || aiService.customModel.isEmpty || apiKey.isEmpty)
Spacer()
}
}
} }
}
.padding()
.background(Color.secondary.opacity(0.03))
.cornerRadius(12)
} else {
// API Key Display for other providers if valid
if aiService.isAPIKeyValid {
VStack(alignment: .leading, spacing: 8) {
Text("API Key")
.font(.subheadline)
.foregroundColor(.secondary)
} else if aiService.selectedProvider == .custom {
// Custom Configuration inline
TextField("API Endpoint URL", text: $aiService.customBaseURL)
TextField("Model Name", text: $aiService.customModel)
if aiService.isAPIKeyValid {
HStack { HStack {
Text(String(repeating: "", count: 40)) Text("API Key Set")
.font(.system(.body, design: .monospaced))
Spacer() Spacer()
Button("Remove Key", role: .destructive) {
Button(action: {
aiService.clearAPIKey() aiService.clearAPIKey()
}) {
Label("Remove Key", systemImage: "trash")
.foregroundColor(.red)
} }
.buttonStyle(.borderless)
} }
} } else {
} else {
// API Key Input for other providers
VStack(alignment: .leading, spacing: 8) {
Text("Enter your API Key")
.font(.subheadline)
.foregroundColor(.secondary)
SecureField("API Key", text: $apiKey) SecureField("API Key", text: $apiKey)
.textFieldStyle(RoundedBorderTextFieldStyle()) Button("Verify and Save") {
.font(.system(.body, design: .monospaced)) isVerifying = true
aiService.saveAPIKey(apiKey) { success, errorMessage in
isVerifying = false
if !success {
alertMessage = errorMessage ?? "Verification failed"
showAlert = true
}
apiKey = ""
}
}
.disabled(aiService.customBaseURL.isEmpty || aiService.customModel.isEmpty || apiKey.isEmpty)
}
} else {
// API Key Display for other providers
if aiService.isAPIKeyValid {
HStack {
Text("API Key")
Spacer()
Text("••••••••")
.foregroundColor(.secondary)
Button("Remove", role: .destructive) {
aiService.clearAPIKey()
}
}
} else {
SecureField("API Key", text: $apiKey)
.textFieldStyle(.roundedBorder)
HStack { HStack {
// Get API Key Link
if let url = getAPIKeyURL() {
Link(destination: url) {
HStack {
Image(systemName: "key.fill")
Text("Get API Key")
}
.font(.caption)
.foregroundColor(.blue)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color.blue.opacity(0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)
}
Spacer()
Button(action: { Button(action: {
isVerifying = true isVerifying = true
aiService.saveAPIKey(apiKey) { success, errorMessage in aiService.saveAPIKey(apiKey) { success, errorMessage in
@ -350,66 +236,12 @@ struct APIKeyManagementView: View {
}) { }) {
HStack { HStack {
if isVerifying { if isVerifying {
ProgressView() ProgressView().controlSize(.small)
.scaleEffect(0.5)
.frame(width: 16, height: 16)
} else {
Image(systemName: "checkmark.circle.fill")
} }
Text("Verify and Save") Text("Verify and Save")
} }
} }
.disabled(apiKey.isEmpty)
Spacer()
HStack(spacing: 8) {
Text((aiService.selectedProvider == .groq || aiService.selectedProvider == .gemini || aiService.selectedProvider == .cerebras) ? "Free" : "Paid")
.font(.caption2)
.foregroundColor(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.1))
.cornerRadius(4)
if aiService.selectedProvider != .ollama && aiService.selectedProvider != .custom {
Button {
let url = switch aiService.selectedProvider {
case .groq:
URL(string: "https://console.groq.com/keys")!
case .openAI:
URL(string: "https://platform.openai.com/api-keys")!
case .gemini:
URL(string: "https://makersuite.google.com/app/apikey")!
case .anthropic:
URL(string: "https://console.anthropic.com/settings/keys")!
case .mistral:
URL(string: "https://console.mistral.ai/api-keys")!
case .elevenLabs:
URL(string: "https://elevenlabs.io/speech-synthesis")!
case .deepgram:
URL(string: "https://console.deepgram.com/api-keys")!
case .soniox:
URL(string: "https://console.soniox.com/")!
case .ollama, .custom:
URL(string: "")! // This case should never be reached
case .openRouter:
URL(string: "https://openrouter.ai/keys")!
case .cerebras:
URL(string: "https://cloud.cerebras.ai/")!
}
NSWorkspace.shared.open(url)
} label: {
HStack(spacing: 4) {
Text("Get API Key")
.foregroundColor(.accentColor)
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.accentColor)
}
}
.buttonStyle(.plain)
}
}
} }
} }
} }
@ -444,8 +276,19 @@ struct APIKeyManagementView: View {
} }
} }
private func formatSize(_ bytes: Int64) -> String { private func getAPIKeyURL() -> URL? {
let gigabytes = Double(bytes) / 1_000_000_000 switch aiService.selectedProvider {
return String(format: "%.1f GB", gigabytes) case .groq: return URL(string: "https://console.groq.com/keys")
case .openAI: return URL(string: "https://platform.openai.com/api-keys")
case .gemini: return URL(string: "https://makersuite.google.com/app/apikey")
case .anthropic: return URL(string: "https://console.anthropic.com/settings/keys")
case .mistral: return URL(string: "https://console.mistral.ai/api-keys")
case .elevenLabs: return URL(string: "https://elevenlabs.io/speech-synthesis")
case .deepgram: return URL(string: "https://console.deepgram.com/api-keys")
case .soniox: return URL(string: "https://console.soniox.com/")
case .openRouter: return URL(string: "https://openrouter.ai/keys")
case .cerebras: return URL(string: "https://cloud.cerebras.ai/")
default: return nil
}
} }
} }

View File

@ -8,103 +8,84 @@ struct EnhancementSettingsView: View {
@State private var selectedPromptForEdit: CustomPrompt? @State private var selectedPromptForEdit: CustomPrompt?
var body: some View { var body: some View {
ScrollView { Form {
VStack(spacing: 32) { Section {
// Main Settings Sections Toggle(isOn: $enhancementService.isEnhancementEnabled) {
VStack(spacing: 24) { HStack(spacing: 4) {
// Enable/Disable Toggle Section Text("Enable Enhancement")
VStack(alignment: .leading, spacing: 12) { InfoTip(
HStack { title: "AI Enhancement",
VStack(alignment: .leading, spacing: 4) { 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.",
HStack { learnMoreURL: "https://tryvoiceink.com/docs/enhancements-configuring-models"
Text("Enable Enhancement")
.font(.headline)
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://www.youtube.com/@tryvoiceink/videos"
)
}
Text("Turn on AI-powered enhancement features")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle("", isOn: $enhancementService.isEnhancementEnabled)
.toggleStyle(SwitchToggleStyle(tint: .blue))
.labelsHidden()
.scaleEffect(1.2)
}
HStack(spacing: 20) {
VStack(alignment: .leading, spacing: 4) {
Toggle("Clipboard Context", isOn: $enhancementService.useClipboardContext)
.toggleStyle(.switch)
.disabled(!enhancementService.isEnhancementEnabled)
Text("Use text from clipboard to understand the context")
.font(.caption)
.foregroundColor(enhancementService.isEnhancementEnabled ? .secondary : .secondary.opacity(0.5))
}
VStack(alignment: .leading, spacing: 4) {
Toggle("Context Awareness", isOn: $enhancementService.useScreenCaptureContext)
.toggleStyle(.switch)
.disabled(!enhancementService.isEnhancementEnabled)
Text("Learn what is on the screen to understand the context")
.font(.caption)
.foregroundColor(enhancementService.isEnhancementEnabled ? .secondary : .secondary.opacity(0.5))
}
}
}
.padding()
.background(CardBackground(isSelected: false))
// 1. AI Provider Integration Section
VStack(alignment: .leading, spacing: 16) {
Text("AI Provider Integration")
.font(.headline)
APIKeyManagementView()
}
.padding()
.background(CardBackground(isSelected: false))
// 3. Enhancement Modes & Assistant Section
VStack(alignment: .leading, spacing: 16) {
Text("Enhancement Prompt")
.font(.headline)
// Reorderable prompts grid with drag-and-drop
ReorderablePromptGrid(
selectedPromptId: enhancementService.selectedPromptId,
onPromptSelected: { prompt in
enhancementService.setActivePrompt(prompt)
},
onEditPrompt: { prompt in
selectedPromptForEdit = prompt
},
onDeletePrompt: { prompt in
enhancementService.deletePrompt(prompt)
},
onAddNewPrompt: {
isEditingPrompt = true
}
) )
} }
.padding()
.background(CardBackground(isSelected: false))
EnhancementShortcutsSection()
} }
.toggleStyle(.switch)
// Context Toggles in the same row
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")
} }
.padding(24)
// API Key Management (Consolidated Section)
APIKeyManagementView()
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
Section("Enhancement Prompts") {
// Reorderable prompts grid with drag-and-drop
ReorderablePromptGrid(
selectedPromptId: enhancementService.selectedPromptId,
onPromptSelected: { prompt in
enhancementService.setActivePrompt(prompt)
},
onEditPrompt: { prompt in
selectedPromptForEdit = prompt
},
onDeletePrompt: { prompt in
enhancementService.deletePrompt(prompt)
},
onAddNewPrompt: {
isEditingPrompt = true
}
)
.padding(.vertical, 8)
}
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
Section("Shortcuts") {
EnhancementShortcutsView()
.padding(.vertical, 8)
}
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
} }
.frame(minWidth: 600, minHeight: 500) .formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(Color(NSColor.controlBackgroundColor)) .background(Color(NSColor.controlBackgroundColor))
.frame(minWidth: 500, minHeight: 400)
.sheet(isPresented: $isEditingPrompt) { .sheet(isPresented: $isEditingPrompt) {
PromptEditorView(mode: .add) PromptEditorView(mode: .add)
} }

View File

@ -5,18 +5,54 @@ struct EnhancementShortcutsView: View {
@ObservedObject private var shortcutSettings = EnhancementShortcutSettings.shared @ObservedObject private var shortcutSettings = EnhancementShortcutSettings.shared
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 8) {
ShortcutRow( // Toggle AI Enhancement
title: "Toggle AI Enhancement", HStack(alignment: .center, spacing: 12) {
description: "Quickly enable or disable enhancement while recording.", HStack(spacing: 4) {
keyDisplay: ["", "E"], Text("Toggle AI Enhancement")
isOn: $shortcutSettings.isToggleEnhancementShortcutEnabled .font(.system(size: 13))
)
ShortcutRow( InfoTip(
title: "Switch Enhancement Prompt", title: "Toggle AI Enhancement",
description: "Switch between your saved prompts without touching the UI. Use ⌘1⌘0 to activate the corresponding prompt in the order they are saved.", message: "Quickly enable or disable AI enhancement while recording. Available only when VoiceInk is running and the recorder is visible.",
keyDisplay: ["", "1 0"] learnMoreURL: "https://tryvoiceink.com/docs/enhancement-shortcuts"
) )
}
Spacer()
HStack(spacing: 10) {
HStack(spacing: 4) {
KeyChip(label: "")
KeyChip(label: "E")
}
Toggle("", isOn: $shortcutSettings.isToggleEnhancementShortcutEnabled)
.toggleStyle(.switch)
.labelsHidden()
}
}
// Switch Enhancement Prompt
HStack(alignment: .center, spacing: 12) {
HStack(spacing: 4) {
Text("Switch Enhancement Prompt")
.font(.system(size: 13))
InfoTip(
title: "Switch Enhancement Prompt",
message: "Switch between your saved prompts using ⌘1 through ⌘0 to activate the corresponding prompt in the order they are saved. Available only when VoiceInk is running and the recorder is visible.",
learnMoreURL: "https://tryvoiceink.com/docs/enhancement-shortcuts"
)
}
Spacer()
HStack(spacing: 4) {
KeyChip(label: "")
KeyChip(label: "1 0")
}
}
} }
.background(Color.clear) .background(Color.clear)
} }
@ -33,29 +69,19 @@ struct EnhancementShortcutsSection: View {
} }
} label: { } label: {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "command") Text("Enhancement Shortcuts")
.font(.system(size: 20)) .font(.headline)
.foregroundColor(.accentColor) .foregroundColor(.primary)
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
Text("Enhancement Shortcuts")
.font(.headline)
.foregroundColor(.primary)
Text("Keep enhancement prompts handy")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer() Spacer()
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.rotationEffect(.degrees(isExpanded ? 0 : -90)) .rotationEffect(.degrees(isExpanded ? 0 : -90))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 13, weight: .medium))
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 12)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@ -64,20 +90,15 @@ struct EnhancementShortcutsSection: View {
Divider() Divider()
.transition(.opacity) .transition(.opacity)
VStack(alignment: .leading, spacing: 16) { EnhancementShortcutsView()
EnhancementShortcutsView() .padding(.horizontal, 16)
.padding(.vertical, 12)
Text("Enhancement shortcuts are available only when the recorder is visible and VoiceInk is running.") .transition(
.font(.caption) .asymmetric(
.foregroundColor(.secondary) insertion: .opacity.combined(with: .scale(scale: 0.98, anchor: .top)),
} removal: .opacity
.padding(16) )
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.98, anchor: .top)),
removal: .opacity
) )
)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -86,97 +107,25 @@ struct EnhancementShortcutsSection: View {
} }
// MARK: - Supporting Views // MARK: - Supporting Views
private struct ShortcutRow: View {
let title: String
let description: String
let keyDisplay: [String]
private var isOn: Binding<Bool>?
init(title: String, description: String, keyDisplay: [String], isOn: Binding<Bool>? = nil) {
self.title = title
self.description = description
self.keyDisplay = keyDisplay
self.isOn = isOn
}
var body: some View {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
InfoTip(title: title, message: description, learnMoreURL: "https://tryvoiceink.com/docs/switching-enhancement-prompts")
}
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
Spacer(minLength: 12)
if let isOn = isOn {
keyDisplayView(isActive: isOn.wrappedValue)
.onTapGesture {
withAnimation(.bouncy) {
isOn.wrappedValue.toggle()
}
}
.contentShape(Rectangle())
} else {
keyDisplayView()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(CardBackground(isSelected: false))
}
@ViewBuilder
private func keyDisplayView(isActive: Bool? = nil) -> some View {
HStack(spacing: 8) {
ForEach(keyDisplay, id: \.self) { key in
KeyChip(label: key, isActive: isActive)
}
}
}
}
private struct KeyChip: View { private struct KeyChip: View {
let label: String let label: String
var isActive: Bool? = nil
var body: some View { var body: some View {
let active = isActive ?? true
Text(label) Text(label)
.font(.system(size: 13, weight: .semibold, design: .rounded)) .font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(active ? .primary : .secondary) .foregroundColor(.primary)
.padding(.horizontal, 10) .padding(.horizontal, 8)
.padding(.vertical, 6) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: 6, style: .continuous) RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill( .fill(Color(NSColor.controlBackgroundColor))
LinearGradient(
gradient: Gradient(colors: [
Color(NSColor.controlBackgroundColor).opacity(active ? 0.9 : 0.6),
Color(NSColor.controlBackgroundColor).opacity(active ? 0.7 : 0.5)
]),
startPoint: .top,
endPoint: .bottom
)
)
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous) RoundedRectangle(cornerRadius: 4, style: .continuous)
.strokeBorder( .strokeBorder(
Color(NSColor.separatorColor).opacity(active ? 0.4 : 0.2), Color(NSColor.separatorColor).opacity(0.5),
lineWidth: 1 lineWidth: 0.5
) )
) )
.shadow(color: Color(NSColor.shadowColor).opacity(active ? 0.15 : 0.05), radius: 2, x: 0, y: 1)
.opacity(active ? 1.0 : 0.6)
} }
} }