diff --git a/VoiceInk/PowerMode/EmojiPickerView.swift b/VoiceInk/PowerMode/EmojiPickerView.swift index 4455258..da72322 100644 --- a/VoiceInk/PowerMode/EmojiPickerView.swift +++ b/VoiceInk/PowerMode/EmojiPickerView.swift @@ -99,7 +99,6 @@ struct EmojiPickerView: View { } } .padding() - .background(.regularMaterial) .frame(minWidth: 260, idealWidth: 300, maxWidth: 320, minHeight: 150, idealHeight: 280, maxHeight: 350) .alert("Emoji in Use", isPresented: $showingEmojiInUseAlert, presenting: emojiForAlert) { emojiStr in Button("OK", role: .cancel) { } @@ -161,10 +160,6 @@ private struct EmojiButton: View { Text(emoji) .font(.largeTitle) .frame(width: 44, height: 44) - .background( - Circle() - .fill(isSelected ? Color.accentColor.opacity(0.25) : Color.clear) - ) .overlay( Circle() .strokeBorder(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) @@ -197,10 +192,6 @@ private struct AddEmojiButton: View { .labelStyle(.iconOnly) .foregroundColor(.accentColor) .frame(width: 44, height: 44) - .background( - Circle() - .fill(Color.secondary.opacity(0.1)) - ) .overlay( Circle() .strokeBorder(Color.gray.opacity(0.3), lineWidth: 1) diff --git a/VoiceInk/PowerMode/PowerModeConfigView.swift b/VoiceInk/PowerMode/PowerModeConfigView.swift index cffc0b0..df0a133 100644 --- a/VoiceInk/PowerMode/PowerModeConfigView.swift +++ b/VoiceInk/PowerMode/PowerModeConfigView.swift @@ -39,9 +39,7 @@ struct ConfigurationView: View { @State private var isAutoSendEnabled = false @State private var isDefault = false - // State for prompt editing (similar to EnhancementSettingsView) - @State private var isEditingPrompt = false - @State private var selectedPromptForEdit: CustomPrompt? + @State private var isShowingDeleteConfirmation = false // PowerMode hotkey configuration @State private var powerModeConfigId: UUID = UUID() @@ -126,550 +124,371 @@ struct ConfigurationView: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Header with Title and Cancel button - HStack { - Text(mode.title) - .font(.largeTitle) - .fontWeight(.bold) - - Spacer() - - if case .edit(let config) = mode { - Button("Delete") { - let alert = NSAlert() - alert.messageText = "Delete Power Mode?" - alert.informativeText = "Are you sure you want to delete the '\(config.name)' power mode? This action cannot be undone." - alert.alertStyle = .warning - alert.addButton(withTitle: "Delete") - alert.addButton(withTitle: "Cancel") - - // Style the Delete button as destructive - alert.buttons[0].hasDestructiveAction = true - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - powerModeManager.removeConfiguration(with: config.id) - presentationMode.wrappedValue.dismiss() + Form { + Section("General") { + HStack(spacing: 12) { + Button { + isShowingEmojiPicker.toggle() + } label: { + Text(selectedEmoji) + .font(.system(size: 22)) + .frame(width: 32, height: 32) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor)) + ) + } + .buttonStyle(.plain) + .popover(isPresented: $isShowingEmojiPicker, arrowEdge: .bottom) { + EmojiPickerView( + selectedEmoji: $selectedEmoji, + isPresented: $isShowingEmojiPicker + ) + } + + TextField("Name", text: $configName) + .textFieldStyle(.roundedBorder) + .focused($isNameFieldFocused) + } + } + + Section("Trigger Scenarios") { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Applications") + Spacer() + AddIconButton(helpText: "Add application") { + loadInstalledApps() + isShowingAppPicker = true } } - .foregroundColor(.red) - .padding(.trailing, 8) + + if selectedAppConfigs.isEmpty { + Text("No applications added") + .foregroundColor(.secondary) + .font(.subheadline) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 44, maximum: 50), spacing: 10)], spacing: 10) { + ForEach(selectedAppConfigs) { appConfig in + ZStack(alignment: .topTrailing) { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appConfig.bundleIdentifier) { + Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path)) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Image(systemName: "app.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 26, height: 26) + .frame(width: 44, height: 44) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(NSColor.controlBackgroundColor)) + ) + } + + Button { + selectedAppConfigs.removeAll(where: { $0.id == appConfig.id }) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .offset(x: 6, y: -6) + } + } + } + .padding(.vertical, 2) + } } - - Button("Cancel") { + .padding(.vertical, 2) + + VStack(alignment: .leading, spacing: 10) { + Text("Websites") + + HStack { + TextField("Enter website URL (e.g., google.com)", text: $newWebsiteURL) + .textFieldStyle(.roundedBorder) + .onSubmit { addWebsite() } + + AddIconButton(helpText: "Add website", isDisabled: newWebsiteURL.isEmpty) { + addWebsite() + } + } + + if websiteConfigs.isEmpty { + Text("No websites added") + .foregroundColor(.secondary) + .font(.subheadline) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 140, maximum: 220), spacing: 10)], spacing: 10) { + ForEach(websiteConfigs) { urlConfig in + HStack(spacing: 6) { + Image(systemName: "globe") + .foregroundColor(.secondary) + Text(urlConfig.url) + .lineLimit(1) + Spacer(minLength: 0) + Button { + websiteConfigs.removeAll(where: { $0.id == urlConfig.id }) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor)) + ) + } + } + .padding(.vertical, 2) + } + } + .padding(.vertical, 2) + } + + Section("Transcription") { + if whisperState.usableModels.isEmpty { + Text("No transcription models available. Please connect to a cloud service or download a local model in the AI Models tab.") + .foregroundColor(.secondary) + } else { + let modelBinding = Binding( + get: { selectedTranscriptionModelName ?? whisperState.usableModels.first?.name }, + set: { selectedTranscriptionModelName = $0 } + ) + + Picker("Model", selection: modelBinding) { + ForEach(whisperState.usableModels, id: \.name) { model in + Text(model.displayName).tag(model.name as String?) + } + } + } + + if languageSelectionDisabled() { + LabeledContent("Language") { + Text("Autodetected") + .foregroundColor(.secondary) + } + } else if let selectedModel = effectiveModelName, + let modelInfo = whisperState.allAvailableModels.first(where: { $0.name == selectedModel }), + modelInfo.isMultilingualModel { + let languageBinding = Binding( + get: { selectedLanguage ?? UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "auto" }, + set: { selectedLanguage = $0 } + ) + + Picker("Language", selection: languageBinding) { + ForEach(modelInfo.supportedLanguages.sorted(by: { + if $0.key == "auto" { return true } + if $1.key == "auto" { return false } + return $0.value < $1.value + }), id: \.key) { key, value in + Text(value).tag(key as String?) + } + } + } else if let selectedModel = effectiveModelName, + let modelInfo = whisperState.allAvailableModels.first(where: { $0.name == selectedModel }), + !modelInfo.isMultilingualModel { + EmptyView() + .onAppear { + if selectedLanguage == nil { + selectedLanguage = "en" + } + } + } + } + + Section("AI Enhancement") { + Toggle("Enable AI Enhancement", isOn: $isAIEnhancementEnabled) + .onChange(of: isAIEnhancementEnabled) { _, newValue in + if newValue { + if selectedAIProvider == nil { + selectedAIProvider = aiService.selectedProvider.rawValue + } + if selectedAIModel == nil { + selectedAIModel = aiService.currentModel + } + if selectedPromptId == nil { + selectedPromptId = enhancementService.allPrompts.first?.id + } + } + } + + let providerBinding = Binding( + get: { + if let providerName = selectedAIProvider, + let provider = AIProvider(rawValue: providerName) { + return provider + } + return aiService.selectedProvider + }, + set: { newValue in + selectedAIProvider = newValue.rawValue + aiService.selectedProvider = newValue + selectedAIModel = nil + } + ) + + if isAIEnhancementEnabled { + if aiService.connectedProviders.isEmpty { + LabeledContent("AI Provider") { + Text("No providers connected") + .foregroundColor(.secondary) + .italic() + } + } else { + Picker("AI Provider", selection: providerBinding) { + ForEach(aiService.connectedProviders.filter { $0 != .elevenLabs && $0 != .deepgram }, id: \.self) { provider in + Text(provider.rawValue).tag(provider) + } + } + .onChange(of: selectedAIProvider) { _, newValue in + if let provider = newValue.flatMap({ AIProvider(rawValue: $0) }) { + selectedAIModel = provider.defaultModel + } + } + } + + let providerName = selectedAIProvider ?? aiService.selectedProvider.rawValue + if let provider = AIProvider(rawValue: providerName), + provider != .custom { + if aiService.availableModels.isEmpty { + LabeledContent("AI Model") { + Text(provider == .openRouter ? "No models loaded" : "No models available") + .foregroundColor(.secondary) + .italic() + } + } else { + let modelBinding = Binding( + get: { + if let model = selectedAIModel, !model.isEmpty { return model } + return aiService.currentModel + }, + set: { newModelValue in + selectedAIModel = newModelValue + aiService.selectModel(newModelValue) + } + ) + + let models = provider == .openRouter ? aiService.availableModels : (provider == .ollama ? aiService.availableModels : provider.availableModels) + + Picker("AI Model", selection: modelBinding) { + ForEach(models, id: \.self) { model in + Text(model).tag(model) + } + } + + if provider == .openRouter { + Button("Refresh Models") { + Task { await aiService.fetchOpenRouterModels() } + } + .help("Refresh models") + } + } + } + + if enhancementService.allPrompts.isEmpty { + LabeledContent("Enhancement Prompt") { + Text("No prompts available") + .foregroundColor(.secondary) + } + } else { + Picker("Enhancement Prompt", selection: $selectedPromptId) { + ForEach(enhancementService.allPrompts) { prompt in + Text(prompt.title).tag(prompt.id as UUID?) + } + } + } + + Toggle("Context Awareness", isOn: $useScreenCapture) + } + } + + Section("Advanced") { + Toggle(isOn: $isDefault) { + HStack(spacing: 6) { + Text("Set as default") + InfoTip( + title: "Default Power Mode", + message: "Default power mode is used when no specific app or website matches are found" + ) + } + } + + Toggle(isOn: $isAutoSendEnabled) { + HStack(spacing: 6) { + Text("Auto Send") + InfoTip( + title: "Auto Send", + message: "Automatically presses the Return/Enter key after pasting text. This is useful for chat applications or forms where its not necessary to to make changes to the transcribed text" + ) + } + } + + HStack { + Text("Keyboard Shortcut") + InfoTip( + title: "Power Mode Hotkey", + message: "Assign a unique keyboard shortcut to instantly activate this Power Mode and start recording" + ) + + Spacer() + + KeyboardShortcuts.Recorder(for: .powerMode(id: powerModeConfigId)) + .controlSize(.regular) + .frame(minHeight: 28) + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .background(Color(NSColor.controlBackgroundColor)) + .navigationTitle(mode.title) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Save") { + saveConfiguration() + } + .keyboardShortcut(.defaultAction) + .disabled(!canSave) + .buttonStyle(.bordered) + .controlSize(.regular) + .padding(.horizontal, 4) + } + + if case .edit = mode { + ToolbarItem { + Button("Delete", role: .destructive) { + isShowingDeleteConfirmation = true + } + .buttonStyle(.bordered) + .controlSize(.regular) + .padding(.horizontal, 4) + } + } + } + .confirmationDialog( + "Delete Power Mode?", + isPresented: $isShowingDeleteConfirmation, + titleVisibility: .visible + ) { + if case .edit(let config) = mode { + Button("Delete", role: .destructive) { + powerModeManager.removeConfiguration(with: config.id) presentationMode.wrappedValue.dismiss() } - .keyboardShortcut(.escape, modifiers: []) } - .padding(.horizontal) - .padding(.top) - .padding(.bottom, 10) - - Divider() - - ScrollView { - VStack(spacing: 20) { - // Main Input Section - VStack(spacing: 16) { - HStack(spacing: 16) { - Button(action: { - isShowingEmojiPicker.toggle() - }) { - ZStack { - Circle() - .fill(Color.accentColor.opacity(0.15)) - .frame(width: 48, height: 48) - - Text(selectedEmoji) - .font(.system(size: 24)) - } - } - .buttonStyle(.plain) - .popover(isPresented: $isShowingEmojiPicker, arrowEdge: .bottom) { - EmojiPickerView( - selectedEmoji: $selectedEmoji, - isPresented: $isShowingEmojiPicker - ) - } - - TextField("Name your power mode", text: $configName) - .font(.system(size: 18, weight: .bold)) - .textFieldStyle(.plain) - .foregroundColor(.primary) - .tint(.accentColor) - .focused($isNameFieldFocused) - } - - // Default Power Mode Toggle - HStack { - Toggle("Set as default power mode", isOn: $isDefault) - .font(.system(size: 14)) - - InfoTip( - title: "Default Power Mode", - message: "Default power mode is used when no specific app or website matches are found" - ) - - Spacer() - } - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - .background(CardBackground(isSelected: false)) - .padding(.horizontal) - .onAppear { - // Add a small delay to ensure the view is fully loaded - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isNameFieldFocused = true - } - } - - VStack(spacing: 16) { - SectionHeader(title: "When to Trigger") - - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Applications") - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - - Button(action: { - loadInstalledApps() - isShowingAppPicker = true - }) { - Label("Add App", systemImage: "plus.circle.fill") - .font(.subheadline) - } - .buttonStyle(.plain) - } - - if selectedAppConfigs.isEmpty { - HStack { - Spacer() - Text("No applications added") - .foregroundColor(.secondary) - .font(.subheadline) - Spacer() - } - .padding() - .background(CardBackground(isSelected: false)) - } else { - // Grid of selected apps that wraps to next line - LazyVGrid(columns: [GridItem(.adaptive(minimum: 50, maximum: 55), spacing: 10)], spacing: 10) { - ForEach(selectedAppConfigs) { appConfig in - VStack { - ZStack(alignment: .topTrailing) { - // App icon - completely filling the container - if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appConfig.bundleIdentifier) { - Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path)) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } else { - Image(systemName: "app.fill") - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - - // Remove button - Button(action: { - selectedAppConfigs.removeAll(where: { $0.id == appConfig.id }) - }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) - .foregroundColor(.white) - .background(Circle().fill(Color.black.opacity(0.6))) - } - .buttonStyle(.plain) - .offset(x: 6, y: -6) - } - } - .frame(width: 50, height: 50) - .background(CardBackground(isSelected: false, cornerRadius: 10)) - } - } - } - } - - Divider() - - VStack(alignment: .leading, spacing: 12) { - Text("Websites") - .font(.subheadline) - .foregroundColor(.secondary) - - // Add URL Field - HStack { - TextField("Enter website URL (e.g., google.com)", text: $newWebsiteURL) - .textFieldStyle(.roundedBorder) - .onSubmit { - addWebsite() - } - - Button(action: addWebsite) { - Image(systemName: "plus.circle.fill") - .foregroundColor(.accentColor) - .font(.system(size: 18)) - } - .buttonStyle(.plain) - .disabled(newWebsiteURL.isEmpty) - } - - if websiteConfigs.isEmpty { - HStack { - Spacer() - Text("No websites added") - .foregroundColor(.secondary) - .font(.subheadline) - Spacer() - } - .padding() - .background(CardBackground(isSelected: false)) - } else { - // Grid of website tags that wraps to next line - LazyVGrid(columns: [GridItem(.adaptive(minimum: 100, maximum: 160), spacing: 10)], spacing: 10) { - ForEach(websiteConfigs) { urlConfig in - HStack(spacing: 4) { - Image(systemName: "globe") - .font(.system(size: 11)) - .foregroundColor(.accentColor) - - Text(urlConfig.url) - .font(.system(size: 11)) - .lineLimit(1) - - Spacer(minLength: 0) - - Button(action: { - websiteConfigs.removeAll(where: { $0.id == urlConfig.id }) - }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 9)) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(height: 28) - .background(CardBackground(isSelected: false, cornerRadius: 10)) - } - } - .padding(8) - } - } - } - .padding() - .background(CardBackground(isSelected: false)) - .padding(.horizontal) - - VStack(spacing: 16) { - SectionHeader(title: "Transcription") - - if whisperState.usableModels.isEmpty { - Text("No transcription models available. Please connect to a cloud service or download a local model in the AI Models tab.") - .font(.subheadline) - .foregroundColor(.secondary) - .padding() - .frame(maxWidth: .infinity, alignment: .center) - .background(CardBackground(isSelected: false)) - } else { - let modelBinding = Binding( - get: { - selectedTranscriptionModelName ?? whisperState.usableModels.first?.name - }, - set: { selectedTranscriptionModelName = $0 } - ) - - HStack { - Text("Model") - .font(.subheadline) - .foregroundColor(.secondary) - - Picker("", selection: modelBinding) { - ForEach(whisperState.usableModels, id: \.name) { model in - Text(model.displayName).tag(model.name as String?) - } - } - .labelsHidden() - - Spacer() - } - } - - if languageSelectionDisabled() { - HStack { - Text("Language") - .font(.subheadline) - .foregroundColor(.secondary) - - Text("Autodetected") - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - } - } else if let selectedModel = effectiveModelName, - let modelInfo = whisperState.allAvailableModels.first(where: { $0.name == selectedModel }), - modelInfo.isMultilingualModel { - - let languageBinding = Binding( - get: { - selectedLanguage ?? UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "auto" - }, - set: { selectedLanguage = $0 } - ) - - HStack { - Text("Language") - .font(.subheadline) - .foregroundColor(.secondary) - - Picker("", selection: languageBinding) { - ForEach(modelInfo.supportedLanguages.sorted(by: { - if $0.key == "auto" { return true } - if $1.key == "auto" { return false } - return $0.value < $1.value - }), id: \.key) { key, value in - Text(value).tag(key as String?) - } - } - .labelsHidden() - - Spacer() - } - } else if let selectedModel = effectiveModelName, - let modelInfo = whisperState.allAvailableModels.first(where: { $0.name == selectedModel }), - !modelInfo.isMultilingualModel { - - EmptyView() - .onAppear { - if selectedLanguage == nil { - selectedLanguage = "en" - } - } - } - } - .padding() - .background(CardBackground(isSelected: false)) - .padding(.horizontal) - - VStack(spacing: 16) { - SectionHeader(title: "AI Enhancement") - - Toggle("Enable AI Enhancement", isOn: $isAIEnhancementEnabled) - .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: isAIEnhancementEnabled) { oldValue, newValue in - if newValue { - if selectedAIProvider == nil { - selectedAIProvider = aiService.selectedProvider.rawValue - } - if selectedAIModel == nil { - selectedAIModel = aiService.currentModel - } - } - } - - Divider() - - let providerBinding = Binding( - get: { - if let providerName = selectedAIProvider, - let provider = AIProvider(rawValue: providerName) { - return provider - } - return aiService.selectedProvider - }, - set: { newValue in - selectedAIProvider = newValue.rawValue - aiService.selectedProvider = newValue - selectedAIModel = nil - } - ) - - - - - if isAIEnhancementEnabled { - - HStack { - Text("AI Provider") - .font(.subheadline) - .foregroundColor(.secondary) - - if aiService.connectedProviders.isEmpty { - Text("No providers connected") - .foregroundColor(.secondary) - .italic() - .frame(maxWidth: .infinity, alignment: .leading) - } else { - Picker("", selection: providerBinding) { - ForEach(aiService.connectedProviders.filter { $0 != .elevenLabs && $0 != .deepgram }, id: \.self) { provider in - Text(provider.rawValue).tag(provider) - } - } - .labelsHidden() - .onChange(of: selectedAIProvider) { oldValue, newValue in - if let provider = newValue.flatMap({ AIProvider(rawValue: $0) }) { - selectedAIModel = provider.defaultModel - } - } - Spacer() - } - } - - let providerName = selectedAIProvider ?? aiService.selectedProvider.rawValue - if let provider = AIProvider(rawValue: providerName), - provider != .custom { - - HStack { - Text("AI Model") - .font(.subheadline) - .foregroundColor(.secondary) - - if aiService.availableModels.isEmpty { - Text(provider == .openRouter ? "No models loaded" : "No models available") - .foregroundColor(.secondary) - .italic() - .frame(maxWidth: .infinity, alignment: .leading) - } else { - let modelBinding = Binding( - get: { - if let model = selectedAIModel, !model.isEmpty { - return model - } - return aiService.currentModel - }, - set: { newModelValue in - selectedAIModel = newModelValue - aiService.selectModel(newModelValue) - } - ) - - let models = provider == .openRouter ? aiService.availableModels : (provider == .ollama ? aiService.availableModels : provider.availableModels) - - Picker("", selection: modelBinding) { - ForEach(models, id: \.self) { model in - Text(model).tag(model) - } - } - .labelsHidden() - - if provider == .openRouter { - Button(action: { - Task { - await aiService.fetchOpenRouterModels() - } - }) { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Refresh models") - } - - Spacer() - } - } - } - - - VStack(alignment: .leading, spacing: 12) { - Text("Enhancement Prompt") - .font(.headline) - .foregroundColor(.primary) - - PromptSelectionGrid( - prompts: enhancementService.allPrompts, - selectedPromptId: selectedPromptId, - onPromptSelected: { prompt in - selectedPromptId = prompt.id - }, - onEditPrompt: { prompt in - selectedPromptForEdit = prompt - }, - onDeletePrompt: { prompt in - enhancementService.deletePrompt(prompt) - }, - onAddNewPrompt: { - isEditingPrompt = true - } - ) - } - - Divider() - - - Toggle("Context Awareness", isOn: $useScreenCapture) - .frame(maxWidth: .infinity, alignment: .leading) - - - } - } - .padding() - .background(CardBackground(isSelected: false)) - .padding(.horizontal) - - VStack(spacing: 16) { - SectionHeader(title: "Advanced") - - HStack { - Toggle("Auto Send", isOn: $isAutoSendEnabled) - - InfoTip( - title: "Auto Send", - message: "Automatically presses the Return/Enter key after pasting text. This is useful for chat applications or forms where its not necessary to to make changes to the transcribed text" - ) - - Spacer() - } - - Divider() - - HStack { - Text("Keyboard Shortcut") - .font(.subheadline) - .foregroundColor(.secondary) - - KeyboardShortcuts.Recorder(for: .powerMode(id: powerModeConfigId)) - .controlSize(.small) - - InfoTip( - title: "Power Mode Hotkey", - message: "Assign a unique keyboard shortcut to instantly activate this Power Mode and start recording" - ) - - Spacer() - } - } - .padding() - .background(CardBackground(isSelected: false)) - .padding(.horizontal) - - HStack { - Spacer() - Button(action: saveConfiguration) { - Text(mode.isAdding ? "Add New Power Mode" : "Save Changes") - .font(.headline) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(canSave ? Color(red: 0.3, green: 0.7, blue: 0.4) : Color(red: 0.3, green: 0.7, blue: 0.4).opacity(0.5)) - ) - } - .buttonStyle(.plain) - .disabled(!canSave) - } - .padding(.horizontal) - } - .padding(.vertical) + Button("Cancel", role: .cancel) { } + } message: { + if case .edit(let config) = mode { + Text("Are you sure you want to delete the '\(config.name)' power mode? This action cannot be undone.") } } .sheet(isPresented: $isShowingAppPicker) { @@ -680,15 +499,7 @@ struct ConfigurationView: View { onDismiss: { isShowingAppPicker = false } ) } - .sheet(isPresented: $isEditingPrompt) { - PromptEditorView(mode: .add) - } - .sheet(item: $selectedPromptForEdit) { prompt in - PromptEditorView(mode: .edit(prompt)) - } .powerModeValidationAlert(errors: validationErrors, isPresented: $showValidationAlert) - .navigationTitle("") // Explicitly set an empty title for this view - .toolbar(.hidden) // Attempt to hide the navigation bar area .onAppear { // Set AI provider and model for new power modes after environment objects are available if case .add = mode { @@ -704,6 +515,11 @@ struct ConfigurationView: View { if isAIEnhancementEnabled && selectedPromptId == nil { selectedPromptId = enhancementService.allPrompts.first?.id } + + // Focus the name field for faster keyboard-driven setup + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isNameFieldFocused = true + } } } diff --git a/VoiceInk/PowerMode/PowerModeView.swift b/VoiceInk/PowerMode/PowerModeView.swift index b9ef0b5..21e468b 100644 --- a/VoiceInk/PowerMode/PowerModeView.swift +++ b/VoiceInk/PowerMode/PowerModeView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData extension View { func placeholder( @@ -69,6 +70,7 @@ struct PowerModeView: View { var body: some View { NavigationStack(path: $navigationPath) { VStack(spacing: 0) { + // Header Section VStack(spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { @@ -80,7 +82,7 @@ struct PowerModeView: View { InfoTip( title: "What is Power Mode?", message: "Automatically apply custom configurations based on the app/website you are using", - learnMoreURL: "https://www.youtube.com/@tryvoiceink/videos" + learnMoreURL: "https://tryvoiceink.com/docs/power-mode" ) } @@ -135,120 +137,122 @@ struct PowerModeView: View { .padding(.horizontal, 24) .padding(.top, 20) .padding(.bottom, 16) + .frame(maxWidth: .infinity) + .background(Color(NSColor.windowBackgroundColor)) - Rectangle() - .fill(Color(NSColor.separatorColor)) - .frame(height: 1) - .padding(.horizontal, 24) - - if isReorderMode { - VStack(spacing: 12) { - List { - ForEach(powerModeManager.configurations) { config in - HStack(spacing: 12) { - ZStack { - Circle() - .fill(Color(NSColor.controlBackgroundColor)) - .frame(width: 40, height: 40) - Text(config.emoji) - .font(.system(size: 20)) - } - - Text(config.name) - .font(.system(size: 15, weight: .semibold)) - - Spacer() - - HStack(spacing: 6) { - if config.isDefault { - Text("Default") - .font(.system(size: 11, weight: .medium)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(Color.accentColor)) - .foregroundColor(.white) + // Content Section + Group { + if isReorderMode { + VStack(spacing: 12) { + List { + ForEach(powerModeManager.configurations) { config in + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color(NSColor.controlBackgroundColor)) + .frame(width: 40, height: 40) + Text(config.emoji) + .font(.system(size: 20)) } - if !config.isEnabled { - Text("Disabled") - .font(.system(size: 11, weight: .medium)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Capsule().fill(Color(NSColor.controlBackgroundColor))) - .overlay( - Capsule().stroke(Color(NSColor.separatorColor), lineWidth: 0.5) - ) - .foregroundColor(.secondary) - } - } - } - .padding(.vertical, 12) - .padding(.horizontal, 14) - .background(CardBackground(isSelected: false)) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.vertical, 6) - } - .onMove(perform: powerModeManager.moveConfigurations) - } - .listStyle(.plain) - .listRowSeparator(.hidden) - .scrollContentBackground(.hidden) - .background(Color(NSColor.controlBackgroundColor)) - } - .padding(.horizontal, 24) - .padding(.vertical, 20) - } else { - GeometryReader { geometry in - ScrollView { - VStack(spacing: 0) { - if powerModeManager.configurations.isEmpty { - VStack(spacing: 24) { + + Text(config.name) + .font(.system(size: 15, weight: .semibold)) + Spacer() - .frame(height: geometry.size.height * 0.2) - - VStack(spacing: 16) { - Image(systemName: "square.grid.2x2.fill") - .font(.system(size: 48, weight: .regular)) - .foregroundColor(.secondary.opacity(0.6)) - - VStack(spacing: 8) { - Text("No Power Modes Yet") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.primary) - - Text("Create first power mode to automate your VoiceInk workflow based on apps/website you are using") - .font(.system(size: 14)) + + HStack(spacing: 6) { + if config.isDefault { + Text("Default") + .font(.system(size: 11, weight: .medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.accentColor)) + .foregroundColor(.white) + } + if !config.isEnabled { + Text("Disabled") + .font(.system(size: 11, weight: .medium)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color(NSColor.controlBackgroundColor))) + .overlay( + Capsule().stroke(Color(NSColor.separatorColor), lineWidth: 0.5) + ) .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .lineSpacing(2) } } - - Spacer() } - .frame(maxWidth: .infinity) - .frame(minHeight: geometry.size.height) - } else { - VStack(spacing: 0) { - PowerModeConfigurationsGrid( - powerModeManager: powerModeManager, - onEditConfig: { config in - configurationMode = .edit(config) - navigationPath.append(configurationMode!) + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background(CardBackground(isSelected: false)) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 6) + } + .onMove(perform: powerModeManager.moveConfigurations) + } + .listStyle(.plain) + .listRowSeparator(.hidden) + .scrollContentBackground(.hidden) + .background(Color(NSColor.controlBackgroundColor)) + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } else { + GeometryReader { geometry in + ScrollView { + VStack(spacing: 0) { + if powerModeManager.configurations.isEmpty { + VStack(spacing: 24) { + Spacer() + .frame(height: geometry.size.height * 0.2) + + VStack(spacing: 16) { + Image(systemName: "square.grid.2x2.fill") + .font(.system(size: 48, weight: .regular)) + .foregroundColor(.secondary.opacity(0.6)) + + VStack(spacing: 8) { + Text("No Power Modes Yet") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.primary) + + Text("Create first power mode to automate your VoiceInk workflow based on apps/website you are using") + .font(.system(size: 14)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(2) + } } - ) - .padding(.horizontal, 24) - .padding(.vertical, 20) - - Spacer() - .frame(height: 40) + + Spacer() + } + .frame(maxWidth: .infinity) + .frame(minHeight: geometry.size.height) + } else { + VStack(spacing: 0) { + PowerModeConfigurationsGrid( + powerModeManager: powerModeManager, + onEditConfig: { config in + configurationMode = .edit(config) + navigationPath.append(configurationMode!) + } + ) + .padding(.horizontal, 24) + .padding(.vertical, 20) + + Spacer() + .frame(height: 40) + } } } } } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.controlBackgroundColor)) } .background(Color(NSColor.controlBackgroundColor)) .navigationDestination(for: ConfigurationMode.self) { mode in @@ -259,11 +263,9 @@ struct PowerModeView: View { } - -// New component for section headers struct SectionHeader: View { let title: String - + var body: some View { Text(title) .font(.system(size: 16, weight: .bold)) diff --git a/VoiceInk/PowerMode/PowerModeViewComponents.swift b/VoiceInk/PowerMode/PowerModeViewComponents.swift index 916ce96..9d006d5 100644 --- a/VoiceInk/PowerMode/PowerModeViewComponents.swift +++ b/VoiceInk/PowerMode/PowerModeViewComponents.swift @@ -65,7 +65,26 @@ struct PowerModeConfigurationsGrid: View { ) } } - .padding(.horizontal) + } +} + +/// Small, consistent icon-only add button used across Power Mode configuration rows. +struct AddIconButton: View { + let helpText: String + var isDisabled: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 18)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(helpText) + .accessibilityLabel(helpText) + .disabled(isDisabled) } } @@ -166,7 +185,7 @@ struct ConfigurationRow: View { .font(.caption2) } } - + if websiteCount > 0 { HStack(spacing: 4) { Image(systemName: "globe") @@ -176,6 +195,7 @@ struct ConfigurationRow: View { } } } + .padding(.top, 2) .foregroundColor(.secondary) } @@ -193,7 +213,6 @@ struct ConfigurationRow: View { if selectedModel != nil || selectedLanguage != nil || config.isAIEnhancementEnabled || config.isAutoSendEnabled { Divider() - .padding(.horizontal, 16) HStack(spacing: 8) { if let model = selectedModel, model != "Default" { @@ -203,8 +222,8 @@ struct ConfigurationRow: View { Text(model) .font(.caption) } - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, 6) + .padding(.vertical, 2) .background(Capsule() .fill(Color(NSColor.controlBackgroundColor))) .overlay( @@ -220,8 +239,8 @@ struct ConfigurationRow: View { Text(language) .font(.caption) } - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, 6) + .padding(.vertical, 2) .background(Capsule() .fill(Color(NSColor.controlBackgroundColor))) .overlay( @@ -237,8 +256,8 @@ struct ConfigurationRow: View { Text(modelName.count > 20 ? String(modelName.prefix(18)) + "..." : modelName) .font(.caption) } - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, 6) + .padding(.vertical, 2) .background(Capsule() .fill(Color(NSColor.controlBackgroundColor))) .overlay( @@ -254,8 +273,8 @@ struct ConfigurationRow: View { Text("Auto Send") .font(.caption) } - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, 6) + .padding(.vertical, 2) .background(Capsule() .fill(Color(NSColor.controlBackgroundColor))) .overlay( @@ -271,8 +290,8 @@ struct ConfigurationRow: View { Text("Context Awareness") .font(.caption) } - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, 6) + .padding(.vertical, 2) .background(Capsule() .fill(Color(NSColor.controlBackgroundColor))) .overlay( @@ -287,8 +306,8 @@ struct ConfigurationRow: View { Text(selectedPrompt?.title ?? "AI") .font(.caption) } - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, 6) + .padding(.vertical, 2) .background(Capsule() .fill(Color.accentColor.opacity(0.1))) .foregroundColor(.accentColor) @@ -296,10 +315,13 @@ struct ConfigurationRow: View { Spacer() } - .padding(.vertical, 10) + + .padding(.vertical, 6) .padding(.horizontal, 16) + .background(Color.secondary.opacity(0.1)) } } + .clipShape(RoundedRectangle(cornerRadius: 16)) .background(CardBackground(isSelected: isEditing)) .opacity(config.isEnabled ? 1.0 : 0.5)