diff --git a/VoiceInk.xcodeproj/project.pbxproj b/VoiceInk.xcodeproj/project.pbxproj index 7fc9f87..8322baf 100644 --- a/VoiceInk.xcodeproj/project.pbxproj +++ b/VoiceInk.xcodeproj/project.pbxproj @@ -7,11 +7,11 @@ objects = { /* Begin PBXBuildFile section */ - E172CAF02DB35C5300937883 /* whisper.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E136D0102DA3EE57000E1E8A /* whisper.xcframework */; }; - E172CAF12DB35C5300937883 /* whisper.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E136D0102DA3EE57000E1E8A /* whisper.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E1A261112CC143AC00B233D1 /* KeyboardShortcuts */; }; E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD4592CC5352A00303ECB /* LaunchAtLogin */; }; E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD45E2CC544F100303ECB /* Sparkle */; }; + E1C7A8112DE06FC60034EDA0 /* whisper.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E11CB51D2DB1F8AF00F9F3ED /* whisper.xcframework */; }; + E1C7A8122DE06FC60034EDA0 /* whisper.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E11CB51D2DB1F8AF00F9F3ED /* whisper.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E1F5FA7A2DA6CBF900B1FD8A /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = E1F5FA792DA6CBF900B1FD8A /* Zip */; }; /* End PBXBuildFile section */ @@ -33,13 +33,13 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - E172CAF22DB35C5300937883 /* Embed Frameworks */ = { + E1C7A8132DE06FC70034EDA0 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - E172CAF12DB35C5300937883 /* whisper.xcframework in Embed Frameworks */, + E1C7A8122DE06FC60034EDA0 /* whisper.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -80,7 +80,7 @@ E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */, E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */, E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */, - E172CAF02DB35C5300937883 /* whisper.xcframework in Frameworks */, + E1C7A8112DE06FC60034EDA0 /* whisper.xcframework in Frameworks */, E1F5FA7A2DA6CBF900B1FD8A /* Zip in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -142,7 +142,7 @@ E11473AC2CBE0F0A00318EE4 /* Sources */, E11473AD2CBE0F0A00318EE4 /* Frameworks */, E11473AE2CBE0F0A00318EE4 /* Resources */, - E172CAF22DB35C5300937883 /* Embed Frameworks */, + E1C7A8132DE06FC70034EDA0 /* Embed Frameworks */, ); buildRules = ( ); diff --git a/VoiceInk/Models/PowerModeConfig.swift b/VoiceInk/Models/PowerModeConfig.swift deleted file mode 100644 index 09ed05c..0000000 --- a/VoiceInk/Models/PowerModeConfig.swift +++ /dev/null @@ -1,168 +0,0 @@ -import Foundation - -struct PowerModeConfig: Codable, Identifiable, Equatable { - var id: String { bundleIdentifier } - let bundleIdentifier: String - var appName: String - var isAIEnhancementEnabled: Bool - var selectedPrompt: String? // UUID string of the selected prompt - var urlConfigs: [URLConfig]? // Optional URL configurations - - static func == (lhs: PowerModeConfig, rhs: PowerModeConfig) -> Bool { - lhs.bundleIdentifier == rhs.bundleIdentifier - } -} - -// Simple URL configuration -struct URLConfig: Codable, Identifiable, Equatable { - let id: UUID - var url: String // Simple URL like "google.com" - var promptId: String? // UUID string of the selected prompt for this URL - - init(url: String, promptId: String? = nil) { - self.id = UUID() - self.url = url - self.promptId = promptId - } -} - -class PowerModeManager: ObservableObject { - static let shared = PowerModeManager() - @Published var configurations: [PowerModeConfig] = [] - @Published var defaultConfig: PowerModeConfig - @Published var isPowerModeEnabled: Bool - - private let configKey = "powerModeConfigurations" - private let defaultConfigKey = "defaultPowerModeConfig" - private let powerModeEnabledKey = "isPowerModeEnabled" - - private init() { - // Load power mode enabled state or default to false if not set - if UserDefaults.standard.object(forKey: powerModeEnabledKey) != nil { - self.isPowerModeEnabled = UserDefaults.standard.bool(forKey: powerModeEnabledKey) - } else { - self.isPowerModeEnabled = false - UserDefaults.standard.set(false, forKey: powerModeEnabledKey) - } - - // Initialize default config with default values - if let data = UserDefaults.standard.data(forKey: defaultConfigKey), - let config = try? JSONDecoder().decode(PowerModeConfig.self, from: data) { - defaultConfig = config - } else { - defaultConfig = PowerModeConfig( - bundleIdentifier: "default", - appName: "Default Configuration", - isAIEnhancementEnabled: false, - selectedPrompt: nil - ) - saveDefaultConfig() - } - loadConfigurations() - } - - private func loadConfigurations() { - if let data = UserDefaults.standard.data(forKey: configKey), - let configs = try? JSONDecoder().decode([PowerModeConfig].self, from: data) { - configurations = configs - } - } - - func saveConfigurations() { - if let data = try? JSONEncoder().encode(configurations) { - UserDefaults.standard.set(data, forKey: configKey) - } - } - - private func saveDefaultConfig() { - if let data = try? JSONEncoder().encode(defaultConfig) { - UserDefaults.standard.set(data, forKey: defaultConfigKey) - } - } - - func addConfiguration(_ config: PowerModeConfig) { - if !configurations.contains(config) { - configurations.append(config) - saveConfigurations() - } - } - - func removeConfiguration(for bundleIdentifier: String) { - configurations.removeAll { $0.bundleIdentifier == bundleIdentifier } - saveConfigurations() - } - - func getConfiguration(for bundleIdentifier: String) -> PowerModeConfig? { - if bundleIdentifier == "default" { - return defaultConfig - } - return configurations.first { $0.bundleIdentifier == bundleIdentifier } - } - - func updateConfiguration(_ config: PowerModeConfig) { - if config.bundleIdentifier == "default" { - defaultConfig = config - saveDefaultConfig() - } else if let index = configurations.firstIndex(where: { $0.bundleIdentifier == config.bundleIdentifier }) { - configurations[index] = config - saveConfigurations() - } - } - - // Get configuration for a specific URL - func getConfigurationForURL(_ url: String) -> (config: PowerModeConfig, urlConfig: URLConfig)? { - let cleanedURL = url.lowercased() - .replacingOccurrences(of: "https://", with: "") - .replacingOccurrences(of: "http://", with: "") - .replacingOccurrences(of: "www.", with: "") - - for config in configurations { - if let urlConfigs = config.urlConfigs { - for urlConfig in urlConfigs { - let configURL = urlConfig.url.lowercased() - .replacingOccurrences(of: "https://", with: "") - .replacingOccurrences(of: "http://", with: "") - .replacingOccurrences(of: "www.", with: "") - - if cleanedURL.contains(configURL) { - return (config, urlConfig) - } - } - } - } - return nil - } - - // Add URL configuration - func addURLConfig(_ urlConfig: URLConfig, to config: PowerModeConfig) { - if var updatedConfig = configurations.first(where: { $0.id == config.id }) { - var configs = updatedConfig.urlConfigs ?? [] - configs.append(urlConfig) - updatedConfig.urlConfigs = configs - updateConfiguration(updatedConfig) - } - } - - // Remove URL configuration - func removeURLConfig(_ urlConfig: URLConfig, from config: PowerModeConfig) { - if var updatedConfig = configurations.first(where: { $0.id == config.id }) { - updatedConfig.urlConfigs?.removeAll(where: { $0.id == urlConfig.id }) - updateConfiguration(updatedConfig) - } - } - - // Update URL configuration - func updateURLConfig(_ urlConfig: URLConfig, in config: PowerModeConfig) { - if var updatedConfig = configurations.first(where: { $0.id == config.id }) { - if let index = updatedConfig.urlConfigs?.firstIndex(where: { $0.id == urlConfig.id }) { - updatedConfig.urlConfigs?[index] = urlConfig - updateConfiguration(updatedConfig) - } - } - } - - // Save power mode enabled state - func savePowerModeEnabled() { - UserDefaults.standard.set(isPowerModeEnabled, forKey: powerModeEnabledKey) - } -} \ No newline at end of file diff --git a/VoiceInk/Services/ActiveWindowService.swift b/VoiceInk/PowerMode/ActiveWindowService.swift similarity index 55% rename from VoiceInk/Services/ActiveWindowService.swift rename to VoiceInk/PowerMode/ActiveWindowService.swift index 499b800..7ec0fc8 100644 --- a/VoiceInk/Services/ActiveWindowService.swift +++ b/VoiceInk/PowerMode/ActiveWindowService.swift @@ -7,6 +7,7 @@ class ActiveWindowService: ObservableObject { @Published var currentApplication: NSRunningApplication? private var enhancementService: AIEnhancementService? private let browserURLService = BrowserURLService.shared + private var whisperState: WhisperState? private let logger = Logger( subsystem: "com.prakashjoshipax.VoiceInk", @@ -19,6 +20,10 @@ class ActiveWindowService: ObservableObject { self.enhancementService = enhancementService } + func configureWhisperState(_ whisperState: WhisperState) { + self.whisperState = whisperState + } + func applyConfigurationForCurrentApp() async { // If power mode is disabled, don't do anything guard PowerModeManager.shared.isPowerModeEnabled else { @@ -45,12 +50,10 @@ class ActiveWindowService: ObservableObject { logger.debug("📍 Successfully got URL: \(currentURL)") // Check for URL-specific configuration - if let (config, urlConfig) = PowerModeManager.shared.getConfigurationForURL(currentURL) { - logger.debug("⚙️ Found URL Configuration: \(config.appName) - URL: \(urlConfig.url)") + if let config = PowerModeManager.shared.getConfigurationForURL(currentURL) { + logger.debug("⚙️ Found URL Configuration: \(config.name) for URL: \(currentURL)") // Apply URL-specific configuration - var updatedConfig = config - updatedConfig.selectedPrompt = urlConfig.promptId - await applyConfiguration(updatedConfig) + await applyConfiguration(config) return } else { logger.debug("📝 No URL configuration found for: \(currentURL)") @@ -61,8 +64,8 @@ class ActiveWindowService: ObservableObject { } // Get configuration for the current app or use default if none exists - let config = PowerModeManager.shared.getConfiguration(for: bundleIdentifier) ?? PowerModeManager.shared.defaultConfig - print("⚡️ Using Configuration: \(config.appName) (AI Enhancement: \(config.isAIEnhancementEnabled ? "Enabled" : "Disabled"))") + let config = PowerModeManager.shared.getConfigurationForApp(bundleIdentifier) ?? PowerModeManager.shared.defaultConfig + print("⚡️ Using Configuration: \(config.name) (AI Enhancement: \(config.isAIEnhancementEnabled ? "Enabled" : "Disabled"))") await applyConfiguration(config) } @@ -74,12 +77,13 @@ class ActiveWindowService: ObservableObject { if PowerModeManager.shared.isPowerModeEnabled { // Apply AI enhancement settings enhancementService.isEnhancementEnabled = config.isAIEnhancementEnabled + enhancementService.useScreenCaptureContext = config.useScreenCapture // Handle prompt selection if config.isAIEnhancementEnabled { if let promptId = config.selectedPrompt, let uuid = UUID(uuidString: promptId) { - print("🎯 Applied Prompt: \(enhancementService.allPrompts.first(where: { $0.id == uuid })?.title ?? "Unknown")") + print("🎯 Applied Prompt: \(promptId)") enhancementService.selectedPromptId = uuid } else { // Auto-select first prompt if none is selected and AI is enabled @@ -89,10 +93,51 @@ class ActiveWindowService: ObservableObject { } } } + + // Apply AI provider and model if specified + if config.isAIEnhancementEnabled, + let aiService = enhancementService.getAIService() { + + // Apply AI provider if specified, otherwise use current global provider + if let providerName = config.selectedAIProvider, + let provider = AIProvider(rawValue: providerName) { + print("🤖 Applied AI Provider: \(provider.rawValue)") + aiService.selectedProvider = provider + + // Apply model if specified, otherwise use default model + if let model = config.selectedAIModel, + !model.isEmpty { + print("🧠 Applied AI Model: \(model)") + aiService.selectModel(model) + } else { + print("🧠 Using default model for provider: \(aiService.currentModel)") + } + } else { + print("🤖 Using global AI Provider: \(aiService.selectedProvider.rawValue)") + } + } + + // Apply language selection if specified + if let language = config.selectedLanguage { + print("🌐 Applied Language: \(language)") + UserDefaults.standard.set(language, forKey: "SelectedLanguage") + // Notify that language has changed to update the prompt + NotificationCenter.default.post(name: .languageDidChange, object: nil) + } } else { print("🔌 Power Mode is disabled globally - skipping configuration application") return } } + + // Apply Whisper model selection - do this outside of MainActor to allow async operations + if PowerModeManager.shared.isPowerModeEnabled, + let whisperState = self.whisperState, + let modelName = config.selectedWhisperModel, + let selectedModel = await whisperState.availableModels.first(where: { $0.name == modelName }) { + print("🎤 Applied Whisper Model: \(selectedModel.name)") + // Apply the model selection immediately + await whisperState.setDefaultModel(selectedModel) + } } } diff --git a/VoiceInk/PowerMode/AppPicker.swift b/VoiceInk/PowerMode/AppPicker.swift new file mode 100644 index 0000000..e008948 --- /dev/null +++ b/VoiceInk/PowerMode/AppPicker.swift @@ -0,0 +1,70 @@ +import SwiftUI + +// App Picker Sheet +struct AppPickerSheet: View { + let installedApps: [(url: URL, name: String, bundleId: String, icon: NSImage)] + @Binding var selectedAppConfigs: [AppConfig] + @Binding var searchText: String + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: 16) { + // Header + HStack { + Text("Select Applications") + .font(.headline) + + Spacer() + + Button("Done") { + onDismiss() + } + .keyboardShortcut(.return, modifiers: []) + } + .padding(.horizontal) + .padding(.top) + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search applications...", text: $searchText) + .textFieldStyle(.roundedBorder) + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + + // App Grid + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 100, maximum: 120), spacing: 16)], spacing: 16) { + ForEach(installedApps.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }), id: \.bundleId) { app in + AppGridItem( + app: app, + isSelected: selectedAppConfigs.contains(where: { $0.bundleIdentifier == app.bundleId }), + action: { + toggleAppSelection(app) + } + ) + } + } + .padding() + } + } + .frame(width: 600, height: 500) + } + + private func toggleAppSelection(_ app: (url: URL, name: String, bundleId: String, icon: NSImage)) { + if let index = selectedAppConfigs.firstIndex(where: { $0.bundleIdentifier == app.bundleId }) { + selectedAppConfigs.remove(at: index) + } else { + let appConfig = AppConfig(bundleIdentifier: app.bundleId, appName: app.name) + selectedAppConfigs.append(appConfig) + } + } +} diff --git a/VoiceInk/Services/BrowserURLService.swift b/VoiceInk/PowerMode/BrowserURLService.swift similarity index 100% rename from VoiceInk/Services/BrowserURLService.swift rename to VoiceInk/PowerMode/BrowserURLService.swift diff --git a/VoiceInk/PowerMode/PowerModeConfig.swift b/VoiceInk/PowerMode/PowerModeConfig.swift new file mode 100644 index 0000000..65b292d --- /dev/null +++ b/VoiceInk/PowerMode/PowerModeConfig.swift @@ -0,0 +1,240 @@ +import Foundation + +struct PowerModeConfig: Codable, Identifiable, Equatable { + var id: UUID + var name: String + var emoji: String + var appConfigs: [AppConfig]? + var urlConfigs: [URLConfig]? + var isAIEnhancementEnabled: Bool + var selectedPrompt: String? // UUID string of the selected prompt + var selectedWhisperModel: String? // Name of the selected Whisper model + var selectedLanguage: String? // Language code (e.g., "en", "fr") + var useScreenCapture: Bool + var selectedAIProvider: String? // AI provider name (e.g., "OpenAI", "Gemini") + var selectedAIModel: String? // AI model name (e.g., "gpt-4", "gemini-1.5-pro") + + init(id: UUID = UUID(), name: String, emoji: String, appConfigs: [AppConfig]? = nil, + urlConfigs: [URLConfig]? = nil, isAIEnhancementEnabled: Bool, selectedPrompt: String? = nil, + selectedWhisperModel: String? = nil, selectedLanguage: String? = nil, useScreenCapture: Bool = false, + selectedAIProvider: String? = nil, selectedAIModel: String? = nil) { + self.id = id + self.name = name + self.emoji = emoji + self.appConfigs = appConfigs + self.urlConfigs = urlConfigs + self.isAIEnhancementEnabled = isAIEnhancementEnabled + self.selectedPrompt = selectedPrompt + self.useScreenCapture = useScreenCapture + self.selectedAIProvider = selectedAIProvider ?? UserDefaults.standard.string(forKey: "selectedAIProvider") + self.selectedAIModel = selectedAIModel + + // Use provided values or get from UserDefaults if nil + self.selectedWhisperModel = selectedWhisperModel ?? UserDefaults.standard.string(forKey: "CurrentModel") + self.selectedLanguage = selectedLanguage ?? UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "en" + } + + static func == (lhs: PowerModeConfig, rhs: PowerModeConfig) -> Bool { + lhs.id == rhs.id + } +} + +// App configuration +struct AppConfig: Codable, Identifiable, Equatable { + let id: UUID + var bundleIdentifier: String + var appName: String + + init(id: UUID = UUID(), bundleIdentifier: String, appName: String) { + self.id = id + self.bundleIdentifier = bundleIdentifier + self.appName = appName + } + + static func == (lhs: AppConfig, rhs: AppConfig) -> Bool { + lhs.id == rhs.id + } +} + +// Simple URL configuration +struct URLConfig: Codable, Identifiable, Equatable { + let id: UUID + var url: String // Simple URL like "google.com" + + init(id: UUID = UUID(), url: String) { + self.id = id + self.url = url + } + + static func == (lhs: URLConfig, rhs: URLConfig) -> Bool { + lhs.id == rhs.id + } +} + +class PowerModeManager: ObservableObject { + static let shared = PowerModeManager() + @Published var configurations: [PowerModeConfig] = [] + @Published var defaultConfig: PowerModeConfig + @Published var isPowerModeEnabled: Bool + + private let configKey = "powerModeConfigurationsV2" + private let defaultConfigKey = "defaultPowerModeConfigV2" + private let powerModeEnabledKey = "isPowerModeEnabled" + + private init() { + // Load power mode enabled state or default to false if not set + if UserDefaults.standard.object(forKey: powerModeEnabledKey) != nil { + self.isPowerModeEnabled = UserDefaults.standard.bool(forKey: powerModeEnabledKey) + } else { + self.isPowerModeEnabled = false + UserDefaults.standard.set(false, forKey: powerModeEnabledKey) + } + + // Initialize default config with default values + if let data = UserDefaults.standard.data(forKey: defaultConfigKey), + let config = try? JSONDecoder().decode(PowerModeConfig.self, from: data) { + defaultConfig = config + } else { + // Get default values from UserDefaults if available + let defaultModelName = UserDefaults.standard.string(forKey: "CurrentModel") + let defaultLanguage = UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "en" + + defaultConfig = PowerModeConfig( + id: UUID(), + name: "Default Configuration", + emoji: "⚙️", + isAIEnhancementEnabled: false, + selectedPrompt: nil, + selectedWhisperModel: defaultModelName, + selectedLanguage: defaultLanguage + ) + saveDefaultConfig() + } + loadConfigurations() + } + + private func loadConfigurations() { + if let data = UserDefaults.standard.data(forKey: configKey), + let configs = try? JSONDecoder().decode([PowerModeConfig].self, from: data) { + configurations = configs + } + } + + func saveConfigurations() { + if let data = try? JSONEncoder().encode(configurations) { + UserDefaults.standard.set(data, forKey: configKey) + } + } + + private func saveDefaultConfig() { + if let data = try? JSONEncoder().encode(defaultConfig) { + UserDefaults.standard.set(data, forKey: defaultConfigKey) + } + } + + func addConfiguration(_ config: PowerModeConfig) { + if !configurations.contains(where: { $0.id == config.id }) { + configurations.append(config) + saveConfigurations() + } + } + + func removeConfiguration(with id: UUID) { + configurations.removeAll { $0.id == id } + saveConfigurations() + } + + func getConfiguration(with id: UUID) -> PowerModeConfig? { + return configurations.first { $0.id == id } + } + + func updateConfiguration(_ config: PowerModeConfig) { + if config.id == defaultConfig.id { + defaultConfig = config + saveDefaultConfig() + } else if let index = configurations.firstIndex(where: { $0.id == config.id }) { + configurations[index] = config + saveConfigurations() + } + } + + // Get configuration for a specific URL + func getConfigurationForURL(_ url: String) -> PowerModeConfig? { + let cleanedURL = cleanURL(url) + + for config in configurations { + if let urlConfigs = config.urlConfigs { + for urlConfig in urlConfigs { + let configURL = cleanURL(urlConfig.url) + + if cleanedURL.contains(configURL) { + return config + } + } + } + } + return nil + } + + // Get configuration for an application bundle ID + func getConfigurationForApp(_ bundleId: String) -> PowerModeConfig? { + for config in configurations { + if let appConfigs = config.appConfigs { + if appConfigs.contains(where: { $0.bundleIdentifier == bundleId }) { + return config + } + } + } + return nil + } + + // Add app configuration + func addAppConfig(_ appConfig: AppConfig, to config: PowerModeConfig) { + if var updatedConfig = configurations.first(where: { $0.id == config.id }) { + var configs = updatedConfig.appConfigs ?? [] + configs.append(appConfig) + updatedConfig.appConfigs = configs + updateConfiguration(updatedConfig) + } + } + + // Remove app configuration + func removeAppConfig(_ appConfig: AppConfig, from config: PowerModeConfig) { + if var updatedConfig = configurations.first(where: { $0.id == config.id }) { + updatedConfig.appConfigs?.removeAll(where: { $0.id == appConfig.id }) + updateConfiguration(updatedConfig) + } + } + + // Add URL configuration + func addURLConfig(_ urlConfig: URLConfig, to config: PowerModeConfig) { + if var updatedConfig = configurations.first(where: { $0.id == config.id }) { + var configs = updatedConfig.urlConfigs ?? [] + configs.append(urlConfig) + updatedConfig.urlConfigs = configs + updateConfiguration(updatedConfig) + } + } + + // Remove URL configuration + func removeURLConfig(_ urlConfig: URLConfig, from config: PowerModeConfig) { + if var updatedConfig = configurations.first(where: { $0.id == config.id }) { + updatedConfig.urlConfigs?.removeAll(where: { $0.id == urlConfig.id }) + updateConfiguration(updatedConfig) + } + } + + // Clean URL for comparison + func cleanURL(_ url: String) -> String { + return url.lowercased() + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "www.", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Save power mode enabled state + func savePowerModeEnabled() { + UserDefaults.standard.set(isPowerModeEnabled, forKey: powerModeEnabledKey) + } +} \ No newline at end of file diff --git a/VoiceInk/PowerMode/PowerModeConfigView.swift b/VoiceInk/PowerMode/PowerModeConfigView.swift new file mode 100644 index 0000000..d2ee804 --- /dev/null +++ b/VoiceInk/PowerMode/PowerModeConfigView.swift @@ -0,0 +1,747 @@ +import SwiftUI + +struct ConfigurationView: View { + let mode: ConfigurationMode + let powerModeManager: PowerModeManager + @EnvironmentObject var enhancementService: AIEnhancementService + @EnvironmentObject var aiService: AIService + @Environment(\.dismiss) private var dismiss + @Environment(\.presentationMode) private var presentationMode + @FocusState private var isNameFieldFocused: Bool + + // State for configuration + @State private var configName: String = "New Power Mode" + @State private var selectedEmoji: String = "💼" + @State private var isShowingEmojiPicker = false + @State private var isShowingAppPicker = false + @State private var isAIEnhancementEnabled: Bool + @State private var selectedPromptId: UUID? + @State private var selectedWhisperModelName: String? + @State private var selectedLanguage: String? + @State private var installedApps: [(url: URL, name: String, bundleId: String, icon: NSImage)] = [] + @State private var searchText = "" + + // Validation state + @State private var validationErrors: [PowerModeValidationError] = [] + @State private var showValidationAlert = false + + // New state for AI provider and model + @State private var selectedAIProvider: String? + @State private var selectedAIModel: String? + + // App and Website configurations + @State private var selectedAppConfigs: [AppConfig] = [] + @State private var websiteConfigs: [URLConfig] = [] + @State private var newWebsiteURL: String = "" + + // New state for screen capture toggle + @State private var useScreenCapture = false + + // Whisper state for model selection + @EnvironmentObject private var whisperState: WhisperState + + private var filteredApps: [(url: URL, name: String, bundleId: String, icon: NSImage)] { + if searchText.isEmpty { + return installedApps + } + return installedApps.filter { app in + app.name.localizedCaseInsensitiveContains(searchText) || + app.bundleId.localizedCaseInsensitiveContains(searchText) + } + } + + // Simplified computed property for effective model name + private var effectiveModelName: String? { + if let model = selectedWhisperModelName { + return model + } + return whisperState.currentModel?.name ?? whisperState.availableModels.first?.name + } + + init(mode: ConfigurationMode, powerModeManager: PowerModeManager) { + self.mode = mode + self.powerModeManager = powerModeManager + + // Always fetch the most current configuration data + switch mode { + case .add: + _isAIEnhancementEnabled = State(initialValue: true) + _selectedPromptId = State(initialValue: nil) + _selectedWhisperModelName = State(initialValue: nil) + _selectedLanguage = State(initialValue: nil) + _configName = State(initialValue: "") + _selectedEmoji = State(initialValue: "✏️") + _useScreenCapture = State(initialValue: false) + // Default to current global AI provider/model for new configurations - use UserDefaults only + _selectedAIProvider = State(initialValue: UserDefaults.standard.string(forKey: "selectedAIProvider")) + _selectedAIModel = State(initialValue: nil) // Initialize to nil and set it after view appears + case .edit(let config): + // Get the latest version of this config from PowerModeManager + let latestConfig = powerModeManager.getConfiguration(with: config.id) ?? config + _isAIEnhancementEnabled = State(initialValue: latestConfig.isAIEnhancementEnabled) + _selectedPromptId = State(initialValue: latestConfig.selectedPrompt.flatMap { UUID(uuidString: $0) }) + _selectedWhisperModelName = State(initialValue: latestConfig.selectedWhisperModel) + _selectedLanguage = State(initialValue: latestConfig.selectedLanguage) + _configName = State(initialValue: latestConfig.name) + _selectedEmoji = State(initialValue: latestConfig.emoji) + _selectedAppConfigs = State(initialValue: latestConfig.appConfigs ?? []) + _websiteConfigs = State(initialValue: latestConfig.urlConfigs ?? []) + _useScreenCapture = State(initialValue: latestConfig.useScreenCapture) + _selectedAIProvider = State(initialValue: latestConfig.selectedAIProvider) + _selectedAIModel = State(initialValue: latestConfig.selectedAIModel) + case .editDefault(let config): + // Always use the latest default config + let latestConfig = powerModeManager.defaultConfig + _isAIEnhancementEnabled = State(initialValue: latestConfig.isAIEnhancementEnabled) + _selectedPromptId = State(initialValue: latestConfig.selectedPrompt.flatMap { UUID(uuidString: $0) }) + _selectedWhisperModelName = State(initialValue: latestConfig.selectedWhisperModel) + _selectedLanguage = State(initialValue: latestConfig.selectedLanguage) + _configName = State(initialValue: latestConfig.name) + _selectedEmoji = State(initialValue: latestConfig.emoji) + _useScreenCapture = State(initialValue: latestConfig.useScreenCapture) + _selectedAIProvider = State(initialValue: latestConfig.selectedAIProvider) + _selectedAIModel = State(initialValue: latestConfig.selectedAIModel) + } + } + + 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") { + powerModeManager.removeConfiguration(with: config.id) + presentationMode.wrappedValue.dismiss() + } + .foregroundColor(.red) + .padding(.trailing, 8) + } + + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .keyboardShortcut(.escape, modifiers: []) + } + .padding(.horizontal) + .padding(.top) + .padding(.bottom, 10) + + Divider() + + ScrollView { + VStack(spacing: 20) { + // Main Input Section + 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) + + TextField("Name your power mode", text: $configName) + .font(.system(size: 18, weight: .bold)) + .textFieldStyle(.plain) + .foregroundColor(.primary) + .tint(.accentColor) + .disabled(mode.isEditingDefault) + .focused($isNameFieldFocused) + .onAppear { + if !mode.isEditingDefault { + isNameFieldFocused = true + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(CardBackground(isSelected: false)) + .padding(.horizontal) + + // Emoji Picker Overlay + if isShowingEmojiPicker { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))], spacing: 12) { + ForEach(commonEmojis, id: \.self) { emoji in + Button(action: { + selectedEmoji = emoji + isShowingEmojiPicker = false + }) { + Text(emoji) + .font(.system(size: 22)) + .frame(width: 40, height: 40) + .background( + Circle() + .fill(selectedEmoji == emoji ? + Color.accentColor.opacity(0.15) : + Color.clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(16) + .background(CardBackground(isSelected: false)) + .padding(.horizontal) + } + + // SECTION 1: TRIGGERS + if !mode.isEditingDefault { + VStack(spacing: 16) { + // Section Header + SectionHeader(title: "When to Trigger") + + // Applications Subsection + 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(Color(.windowBackgroundColor).opacity(0.2)) + .cornerRadius(8) + } 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() + + // Websites Subsection + 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(Color(.windowBackgroundColor).opacity(0.2)) + .cornerRadius(8) + } 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( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.windowBackgroundColor).opacity(0.4)) + ) + .padding(.horizontal) + } + + // SECTION 2: TRANSCRIPTION + VStack(spacing: 16) { + // Section Header + SectionHeader(title: "Transcription") + + // Whisper Model Selection Subsection + if whisperState.availableModels.isEmpty { + Text("No Whisper models available. Download models in the AI Models tab.") + .font(.subheadline) + .foregroundColor(.secondary) + .padding() + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(.windowBackgroundColor).opacity(0.2)) + .cornerRadius(8) + } else { + // Create a simple binding that uses current model if nil + let modelBinding = Binding( + get: { + selectedWhisperModelName ?? whisperState.currentModel?.name ?? whisperState.availableModels.first?.name + }, + set: { selectedWhisperModelName = $0 } + ) + + HStack { + Text("Model") + .font(.subheadline) + .foregroundColor(.secondary) + + Picker("", selection: modelBinding) { + ForEach(whisperState.availableModels) { model in + let displayName = whisperState.predefinedModels.first { $0.name == model.name }?.displayName ?? model.name + Text(displayName).tag(model.name as String?) + } + } + .labelsHidden() + .frame(maxWidth: .infinity) + } + } + + // Language Selection Subsection + if let selectedModel = effectiveModelName, + let modelInfo = whisperState.predefinedModels.first(where: { $0.name == selectedModel }), + modelInfo.isMultilingualModel { + + // Create a simple binding that uses UserDefaults language if nil + 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() + .frame(maxWidth: .infinity) + } + } else if let selectedModel = effectiveModelName, + let modelInfo = whisperState.predefinedModels.first(where: { $0.name == selectedModel }), + !modelInfo.isMultilingualModel { + // Silently set to English without showing UI + EmptyView() + .onAppear { + selectedLanguage = "en" + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.windowBackgroundColor).opacity(0.4)) + ) + .padding(.horizontal) + + // SECTION 3: AI ENHANCEMENT + VStack(spacing: 16) { + // Section Header + SectionHeader(title: "AI Enhancement") + + Toggle("Enable AI Enhancement", isOn: $isAIEnhancementEnabled) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: isAIEnhancementEnabled) { oldValue, newValue in + if newValue { + // When enabling AI enhancement, set default values if none are selected + if selectedAIProvider == nil { + selectedAIProvider = aiService.selectedProvider.rawValue + } + if selectedAIModel == nil { + selectedAIModel = aiService.currentModel + } + } + } + + Divider() + + // AI Provider Selection - Match style with Whisper model selection + // Create a binding for the provider selection that falls back to global settings + let providerBinding = Binding( + get: { + if let providerName = selectedAIProvider, + let provider = AIProvider(rawValue: providerName) { + return provider + } + // Just return the global provider without modifying state + return aiService.selectedProvider + }, + set: { newValue in + selectedAIProvider = newValue.rawValue + // Reset model when provider changes + 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, id: \.self) { provider in + Text(provider.rawValue).tag(provider) + } + } + .labelsHidden() + .frame(maxWidth: .infinity) + .onChange(of: selectedAIProvider) { oldValue, newValue in + // When provider changes, ensure we have a valid model for that provider + if let provider = newValue.flatMap({ AIProvider(rawValue: $0) }) { + // Set default model for this provider + selectedAIModel = provider.defaultModel + } + } + } + } + + // AI Model Selection - Match style with whisper language selection + let providerName = selectedAIProvider ?? aiService.selectedProvider.rawValue + if let provider = AIProvider(rawValue: providerName), + provider != .custom { + + HStack { + Text("AI Model") + .font(.subheadline) + .foregroundColor(.secondary) + + if provider == .ollama && aiService.availableModels.isEmpty { + Text("No models available") + .foregroundColor(.secondary) + .italic() + .frame(maxWidth: .infinity, alignment: .leading) + } else { + // Create binding that falls back to current model for the selected provider + let modelBinding = Binding( + get: { + if let model = selectedAIModel, !model.isEmpty { + return model + } + // Just return the current model without modifying state + return aiService.currentModel + }, + set: { selectedAIModel = $0 } + ) + + let models = provider == .ollama ? aiService.availableModels : provider.availableModels + + Picker("", selection: modelBinding) { + ForEach(models, id: \.self) { model in + Text(model).tag(model) + } + } + .labelsHidden() + .frame(maxWidth: .infinity) + } + } + } + + + // Prompt selection grid + let columns = [ + GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 20) + ] + + LazyVGrid(columns: columns, spacing: 16) { + ForEach(enhancementService.allPrompts) { prompt in + prompt.promptIcon( + isSelected: selectedPromptId == prompt.id, + onTap: { selectedPromptId = prompt.id } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + + // Add Screen Capture toggle + Toggle("Context Awareness", isOn: $useScreenCapture) + .frame(maxWidth: .infinity, alignment: .leading) + + + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.windowBackgroundColor).opacity(0.4)) + ) + .padding(.horizontal) + + // Save Button + VoiceInkButton( + title: mode.isAdding ? "Add New Power Mode" : "Save Changes", + action: saveConfiguration, + isDisabled: !canSave + ) + .frame(maxWidth: .infinity) + .padding(.horizontal) + } + .padding(.vertical) + } + } + .sheet(isPresented: $isShowingAppPicker) { + AppPickerSheet( + installedApps: filteredApps, + selectedAppConfigs: $selectedAppConfigs, + searchText: $searchText, + onDismiss: { isShowingAppPicker = false } + ) + } + .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 { + if selectedAIProvider == nil { + selectedAIProvider = aiService.selectedProvider.rawValue + } + if selectedAIModel == nil || selectedAIModel?.isEmpty == true { + selectedAIModel = aiService.currentModel + } + } + } + } + + private var canSave: Bool { + return !configName.isEmpty + } + + private func addWebsite() { + guard !newWebsiteURL.isEmpty else { return } + + let cleanedURL = powerModeManager.cleanURL(newWebsiteURL) + let urlConfig = URLConfig(url: cleanedURL) + websiteConfigs.append(urlConfig) + newWebsiteURL = "" + } + + private func toggleAppSelection(_ app: (url: URL, name: String, bundleId: String, icon: NSImage)) { + if let index = selectedAppConfigs.firstIndex(where: { $0.bundleIdentifier == app.bundleId }) { + selectedAppConfigs.remove(at: index) + } else { + let appConfig = AppConfig(bundleIdentifier: app.bundleId, appName: app.name) + selectedAppConfigs.append(appConfig) + } + } + + private func getConfigForForm() -> PowerModeConfig { + switch mode { + case .add: + return PowerModeConfig( + name: configName, + emoji: selectedEmoji, + appConfigs: selectedAppConfigs.isEmpty ? nil : selectedAppConfigs, + urlConfigs: websiteConfigs.isEmpty ? nil : websiteConfigs, + isAIEnhancementEnabled: isAIEnhancementEnabled, + selectedPrompt: selectedPromptId?.uuidString, + selectedWhisperModel: selectedWhisperModelName, + selectedLanguage: selectedLanguage, + useScreenCapture: useScreenCapture, + selectedAIProvider: selectedAIProvider, + selectedAIModel: selectedAIModel + ) + case .edit(let config): + var updatedConfig = config + updatedConfig.name = configName + updatedConfig.emoji = selectedEmoji + updatedConfig.isAIEnhancementEnabled = isAIEnhancementEnabled + updatedConfig.selectedPrompt = selectedPromptId?.uuidString + updatedConfig.selectedWhisperModel = selectedWhisperModelName + updatedConfig.selectedLanguage = selectedLanguage + updatedConfig.appConfigs = selectedAppConfigs.isEmpty ? nil : selectedAppConfigs + updatedConfig.urlConfigs = websiteConfigs.isEmpty ? nil : websiteConfigs + updatedConfig.useScreenCapture = useScreenCapture + updatedConfig.selectedAIProvider = selectedAIProvider + updatedConfig.selectedAIModel = selectedAIModel + return updatedConfig + + case .editDefault(let config): + var updatedConfig = config + updatedConfig.name = configName + updatedConfig.emoji = selectedEmoji + updatedConfig.isAIEnhancementEnabled = isAIEnhancementEnabled + updatedConfig.selectedPrompt = selectedPromptId?.uuidString + updatedConfig.selectedWhisperModel = selectedWhisperModelName + updatedConfig.selectedLanguage = selectedLanguage + updatedConfig.useScreenCapture = useScreenCapture + updatedConfig.selectedAIProvider = selectedAIProvider + updatedConfig.selectedAIModel = selectedAIModel + return updatedConfig + } + } + + private func loadInstalledApps() { + // Get both user-installed and system applications + let userAppURLs = FileManager.default.urls(for: .applicationDirectory, in: .localDomainMask) + let systemAppURLs = FileManager.default.urls(for: .applicationDirectory, in: .systemDomainMask) + let allAppURLs = userAppURLs + systemAppURLs + + let apps = allAppURLs.flatMap { baseURL -> [URL] in + let enumerator = FileManager.default.enumerator( + at: baseURL, + includingPropertiesForKeys: [.isApplicationKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) + + return enumerator?.compactMap { item -> URL? in + guard let url = item as? URL, + url.pathExtension == "app" else { return nil } + return url + } ?? [] + } + + installedApps = apps.compactMap { url in + guard let bundle = Bundle(url: url), + let bundleId = bundle.bundleIdentifier, + let name = (bundle.infoDictionary?["CFBundleName"] as? String) ?? + (bundle.infoDictionary?["CFBundleDisplayName"] as? String) else { + return nil + } + + let icon = NSWorkspace.shared.icon(forFile: url.path) + return (url: url, name: name, bundleId: bundleId, icon: icon) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private func saveConfiguration() { + + + let config = getConfigForForm() + + // Only validate when the user explicitly tries to save + let validator = PowerModeValidator(powerModeManager: powerModeManager) + validationErrors = validator.validateForSave(config: config, mode: mode) + + if !validationErrors.isEmpty { + showValidationAlert = true + return + } + + // If validation passes, save the configuration + switch mode { + case .add: + powerModeManager.addConfiguration(config) + case .edit, .editDefault: + powerModeManager.updateConfiguration(config) + } + + presentationMode.wrappedValue.dismiss() + } +} diff --git a/VoiceInk/PowerMode/PowerModeValidator.swift b/VoiceInk/PowerMode/PowerModeValidator.swift new file mode 100644 index 0000000..4a1c1e0 --- /dev/null +++ b/VoiceInk/PowerMode/PowerModeValidator.swift @@ -0,0 +1,134 @@ +import Foundation +import SwiftUI + +enum PowerModeValidationError: Error, Identifiable { + case emptyName + case duplicateName(String) + case noTriggers + case duplicateAppTrigger(String, String) // (app name, existing power mode name) + case duplicateWebsiteTrigger(String, String) // (website, existing power mode name) + + var id: String { + switch self { + case .emptyName: return "emptyName" + case .duplicateName: return "duplicateName" + case .noTriggers: return "noTriggers" + case .duplicateAppTrigger: return "duplicateAppTrigger" + case .duplicateWebsiteTrigger: return "duplicateWebsiteTrigger" + } + } + + var localizedDescription: String { + switch self { + case .emptyName: + return "Power mode name cannot be empty." + case .duplicateName(let name): + return "A power mode with the name '\(name)' already exists." + case .noTriggers: + return "You must add at least one application or website." + case .duplicateAppTrigger(let appName, let powerModeName): + return "The app '\(appName)' is already configured in the '\(powerModeName)' power mode." + case .duplicateWebsiteTrigger(let website, let powerModeName): + return "The website '\(website)' is already configured in the '\(powerModeName)' power mode." + } + } +} + +struct PowerModeValidator { + private let powerModeManager: PowerModeManager + + init(powerModeManager: PowerModeManager) { + self.powerModeManager = powerModeManager + } + + /// Validates a power mode configuration when the user tries to save it. + /// This validation only happens at save time, not during editing. + func validateForSave(config: PowerModeConfig, mode: ConfigurationMode) -> [PowerModeValidationError] { + var errors: [PowerModeValidationError] = [] + + // Validate name + if config.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errors.append(.emptyName) + } + + // Check for duplicate name + let isDuplicateName = powerModeManager.configurations.contains { existingConfig in + if case .edit(let editConfig) = mode, existingConfig.id == editConfig.id { + // Skip checking against itself when editing + return false + } + return existingConfig.name == config.name + } + + if isDuplicateName { + errors.append(.duplicateName(config.name)) + } + + // For non-default modes, check that there's at least one trigger + if !mode.isEditingDefault { + if (config.appConfigs == nil || config.appConfigs?.isEmpty == true) && + (config.urlConfigs == nil || config.urlConfigs?.isEmpty == true) { + errors.append(.noTriggers) + } + + // Check for duplicate app configurations + if let appConfigs = config.appConfigs { + for appConfig in appConfigs { + for existingConfig in powerModeManager.configurations { + // Skip checking against itself when editing + if case .edit(let editConfig) = mode, existingConfig.id == editConfig.id { + continue + } + + if let existingAppConfigs = existingConfig.appConfigs, + existingAppConfigs.contains(where: { $0.bundleIdentifier == appConfig.bundleIdentifier }) { + errors.append(.duplicateAppTrigger(appConfig.appName, existingConfig.name)) + } + } + } + } + + // Check for duplicate website configurations + if let urlConfigs = config.urlConfigs { + for urlConfig in urlConfigs { + for existingConfig in powerModeManager.configurations { + // Skip checking against itself when editing + if case .edit(let editConfig) = mode, existingConfig.id == editConfig.id { + continue + } + + if let existingUrlConfigs = existingConfig.urlConfigs, + existingUrlConfigs.contains(where: { $0.url == urlConfig.url }) { + errors.append(.duplicateWebsiteTrigger(urlConfig.url, existingConfig.name)) + } + } + } + } + } + + return errors + } +} + +// Alert extension for showing validation errors +extension View { + func powerModeValidationAlert( + errors: [PowerModeValidationError], + isPresented: Binding + ) -> some View { + self.alert( + "Cannot Save Power Mode", + isPresented: isPresented, + actions: { + Button("OK", role: .cancel) {} + }, + message: { + if let firstError = errors.first { + Text(firstError.localizedDescription) + } else { + Text("Please fix the validation errors before saving.") + } + } + ) + } +} \ No newline at end of file diff --git a/VoiceInk/PowerMode/PowerModeView.swift b/VoiceInk/PowerMode/PowerModeView.swift new file mode 100644 index 0000000..e090582 --- /dev/null +++ b/VoiceInk/PowerMode/PowerModeView.swift @@ -0,0 +1,261 @@ +import SwiftUI + +extension View { + func placeholder( + when shouldShow: Bool, + alignment: Alignment = .center, + @ViewBuilder placeholder: () -> Content) -> some View { + + ZStack(alignment: alignment) { + placeholder().opacity(shouldShow ? 1 : 0) + self + } + } +} + +// Configuration Mode Enum +enum ConfigurationMode: Hashable { + case add + case edit(PowerModeConfig) + case editDefault(PowerModeConfig) + + var isAdding: Bool { + if case .add = self { return true } + return false + } + + var isEditingDefault: Bool { + if case .editDefault = self { return true } + return false + } + + var title: String { + switch self { + case .add: return "Add Power Mode" + case .editDefault: return "Edit Default Power Mode" + case .edit: return "Edit Power Mode" + } + } + + // Implement hash(into:) to conform to Hashable + func hash(into hasher: inout Hasher) { + switch self { + case .add: + hasher.combine(0) // Use a unique value for add + case .edit(let config): + hasher.combine(1) // Use a unique value for edit + hasher.combine(config.id) + case .editDefault(let config): + hasher.combine(2) // Use a unique value for editDefault + hasher.combine(config.id) + } + } + + // Implement == to conform to Equatable (required by Hashable) + static func == (lhs: ConfigurationMode, rhs: ConfigurationMode) -> Bool { + switch (lhs, rhs) { + case (.add, .add): + return true + case (.edit(let lhsConfig), .edit(let rhsConfig)): + return lhsConfig.id == rhsConfig.id + case (.editDefault(let lhsConfig), .editDefault(let rhsConfig)): + return lhsConfig.id == rhsConfig.id + default: + return false + } + } +} + +// Configuration Type +enum ConfigurationType { + case application + case website +} + +// Common Emojis for selection +let commonEmojis = ["🏢", "🏠", "💼", "🎮", "📱", "📺", "🎵", "📚", "✏️", "🎨", "🧠", "⚙️", "💻", "🌐", "📝", "📊", "🔍", "💬", "📈", "🔧"] + +// Main Power Mode View with Navigation +struct PowerModeView: View { + @StateObject private var powerModeManager = PowerModeManager.shared + @EnvironmentObject private var enhancementService: AIEnhancementService + @EnvironmentObject private var aiService: AIService + @State private var configurationMode: ConfigurationMode? + @State private var navigationPath = NavigationPath() + + var body: some View { + NavigationStack(path: $navigationPath) { + ScrollView { + VStack(spacing: 24) { + // Header Section + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center) { + Text("Power Mode") + .font(.system(size: 22, weight: .bold)) + + InfoTip( + title: "Power Mode", + message: "Create custom modes that automatically apply when using specific apps/websites.", + learnMoreURL: "https://www.youtube.com/watch?v=cEepexxgf6Y&t=10s" + ) + + Spacer() + + Toggle("", isOn: $powerModeManager.isPowerModeEnabled) + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .labelsHidden() + .scaleEffect(1.2) + .onChange(of: powerModeManager.isPowerModeEnabled) { oldValue, newValue in + powerModeManager.savePowerModeEnabled() + } + } + + Text("Automatically apply custom configurations based on the app/website you are using") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.horizontal) + .padding(.top, 8) + + if powerModeManager.isPowerModeEnabled { + // Configurations Container + VStack(spacing: 0) { + // Default Configuration Section + VStack(alignment: .leading, spacing: 16) { + Text("Default Power Mode") + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal) + .padding(.top, 16) + + ConfigurationRow( + config: powerModeManager.defaultConfig, + isEditing: false, + isDefault: true, + action: { + configurationMode = .editDefault(powerModeManager.defaultConfig) + navigationPath.append(configurationMode!) + } + ) + .padding(.horizontal) + } + + // Divider between sections + Divider() + .padding(.vertical, 16) + + // Custom Configurations Section + VStack(alignment: .leading, spacing: 16) { + Text("Custom Power Modes") + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal) + + if powerModeManager.configurations.isEmpty { + VStack(spacing: 20) { + Image(systemName: "square.grid.2x2") + .font(.system(size: 36)) + .foregroundColor(.secondary) + + Text("No custom power modes") + .font(.title3) + .fontWeight(.medium) + + Text("Create a new mode for specific apps/websites") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 30) + } else { + PowerModeConfigurationsGrid( + powerModeManager: powerModeManager, + onEditConfig: { config in + configurationMode = .edit(config) + navigationPath.append(configurationMode!) + } + ) + } + } + + Spacer(minLength: 24) + + // Add Configuration button at the bottom (centered) + HStack { + VoiceInkButton( + title: "Add New Power Mode", + action: { + configurationMode = .add + navigationPath.append(configurationMode!) + } + ) + } + .padding(.horizontal) + .padding(.bottom, 16) + } + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(NSColor.controlBackgroundColor).opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + .shadow(color: Color(NSColor.shadowColor).opacity(0.05), radius: 5, y: 2) + .padding(.horizontal) + } else { + // Disabled state + VStack(spacing: 24) { + Image(systemName: "bolt.slash.circle") + .font(.system(size: 56)) + .foregroundColor(.secondary) + + Text("Power Mode is disabled") + .font(.title2) + .fontWeight(.semibold) + + Text("Enable Power Mode to create context-specific configurations that automatically apply based on your current app or website.") + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + + VoiceInkButton( + title: "Enable Power Mode", + action: { + powerModeManager.isPowerModeEnabled = true + powerModeManager.savePowerModeEnabled() + } + ) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity) + .padding(40) + .background(Color(NSColor.controlBackgroundColor).opacity(0.9)) + .cornerRadius(16) + .shadow(color: Color(NSColor.shadowColor).opacity(0.05), radius: 5, y: 2) + .padding(.horizontal) + } + } + .padding(.bottom, 24) + } + .navigationTitle("") + .navigationDestination(for: ConfigurationMode.self) { mode in + ConfigurationView(mode: mode, powerModeManager: powerModeManager) + } + } + } +} + + +// New component for section headers +struct SectionHeader: View { + let title: String + + var body: some View { + Text(title) + .font(.system(size: 16, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + } +} diff --git a/VoiceInk/PowerMode/PowerModeViewComponents.swift b/VoiceInk/PowerMode/PowerModeViewComponents.swift new file mode 100644 index 0000000..dd82ba7 --- /dev/null +++ b/VoiceInk/PowerMode/PowerModeViewComponents.swift @@ -0,0 +1,392 @@ +import SwiftUI +// Supporting Views + +// VoiceInk's consistent button component +struct VoiceInkButton: View { + let title: String + let action: () -> Void + var isDisabled: Bool = false + + var body: some View { + Button(action: action) { + Text(title) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isDisabled ? Color.accentColor.opacity(0.5) : Color.accentColor) + ) + } + .buttonStyle(.plain) + .disabled(isDisabled) + } +} + +struct PowerModeEmptyStateView: View { + let action: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "bolt.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text("No Power Modes") + .font(.title2) + .fontWeight(.semibold) + + Text("Add customized power modes for different contexts") + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + VoiceInkButton( + title: "Add New Power Mode", + action: action + ) + .frame(maxWidth: 250) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct PowerModeConfigurationsGrid: View { + @ObservedObject var powerModeManager: PowerModeManager + let onEditConfig: (PowerModeConfig) -> Void + @EnvironmentObject var enhancementService: AIEnhancementService + + var body: some View { + LazyVStack(spacing: 12) { + ForEach(powerModeManager.configurations.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending })) { config in + ConfigurationRow( + config: config, + isEditing: false, + isDefault: false, + action: { + onEditConfig(config) + } + ) + .contextMenu { + Button(action: { + onEditConfig(config) + }) { + Label("Edit", systemImage: "pencil") + } + Button(role: .destructive, action: { + powerModeManager.removeConfiguration(with: config.id) + }) { + Label("Remove", systemImage: "trash") + } + } + } + } + .padding(.horizontal) + } +} + +struct ConfigurationRow: View { + let config: PowerModeConfig + let isEditing: Bool + let isDefault: Bool + let action: () -> Void + @EnvironmentObject var enhancementService: AIEnhancementService + @EnvironmentObject var whisperState: WhisperState + + // How many app icons to show at maximum + private let maxAppIconsToShow = 5 + + // Data properties + private var selectedPrompt: CustomPrompt? { + guard let promptId = config.selectedPrompt, + let uuid = UUID(uuidString: promptId) else { return nil } + return enhancementService.allPrompts.first { $0.id == uuid } + } + + private var selectedModel: String? { + if let modelName = config.selectedWhisperModel, + let model = whisperState.predefinedModels.first(where: { $0.name == modelName }) { + return model.displayName + } + return "Default" + } + + private var selectedLanguage: String? { + if let langCode = config.selectedLanguage { + if langCode == "auto" { return "Auto" } + if langCode == "en" { return "English" } + + if let modelName = config.selectedWhisperModel, + let model = whisperState.predefinedModels.first(where: { $0.name == modelName }), + let langName = model.supportedLanguages[langCode] { + return langName + } + return langCode.uppercased() + } + return "Default" + } + + private var appCount: Int { return config.appConfigs?.count ?? 0 } + private var websiteCount: Int { return config.urlConfigs?.count ?? 0 } + + private var websiteText: String { + if websiteCount == 0 { return "" } + return websiteCount == 1 ? "1 Website" : "\(websiteCount) Websites" + } + + private var appText: String { + if appCount == 0 { return "" } + return appCount == 1 ? "1 App" : "\(appCount) Apps" + } + + private var extraAppsCount: Int { + return max(0, appCount - maxAppIconsToShow) + } + + private var visibleAppConfigs: [AppConfig] { + return Array(config.appConfigs?.prefix(maxAppIconsToShow) ?? []) + } + + var body: some View { + Button(action: action) { + VStack(spacing: 0) { + // Top row: Emoji, Name, and App/Website counts + HStack(spacing: 12) { + // Left: Emoji/Icon + ZStack { + Circle() + .fill(isDefault ? Color.accentColor.opacity(0.15) : Color(.controlBackgroundColor)) + .frame(width: 40, height: 40) + + if isDefault { + Image(systemName: "gearshape.fill") + .font(.system(size: 18)) + .foregroundColor(.accentColor) + } else { + Text(config.emoji) + .font(.system(size: 20)) + } + } + + // Middle: Name and badge + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(config.name) + .font(.system(size: 15, weight: .semibold)) + + if isDefault { + Text("Default") + .font(.system(size: 10, weight: .medium)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(Color.accentColor.opacity(0.15))) + .foregroundColor(.accentColor) + } + } + + if isDefault { + Text("Fallback power mode") + .font(.caption2) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Right: App Icons and Website Count + if !isDefault { + HStack(alignment: .center, spacing: 6) { + // App Count + if appCount > 0 { + HStack(spacing: 3) { + Text(appText) + .font(.caption) + .foregroundColor(.secondary) + + Image(systemName: "app.fill") + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + + // Website Count + if websiteCount > 0 { + HStack(spacing: 3) { + Text(websiteText) + .font(.caption) + .foregroundColor(.secondary) + + Image(systemName: "globe") + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + + // Only add divider and settings row if we have settings + if selectedModel != nil || selectedLanguage != nil || config.isAIEnhancementEnabled { + Divider() + .padding(.horizontal, 16) + + // Settings badges in specified order + HStack(spacing: 8) { + // 1. Voice Model badge + if let model = selectedModel, model != "Default" { + HStack(spacing: 4) { + Image(systemName: "waveform") + .font(.system(size: 10)) + Text(model) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule() + .fill(Color(.controlBackgroundColor))) + .overlay( + Capsule() + .stroke(Color(.separatorColor), lineWidth: 0.5) + ) + } + + // 2. Language badge + if let language = selectedLanguage, language != "Default" { + HStack(spacing: 4) { + Image(systemName: "globe") + .font(.system(size: 10)) + Text(language) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule() + .fill(Color(.controlBackgroundColor))) + .overlay( + Capsule() + .stroke(Color(.separatorColor), lineWidth: 0.5) + ) + } + + // 3. AI Model badge if specified (moved before AI Enhancement) + if config.isAIEnhancementEnabled, let modelName = config.selectedAIModel, !modelName.isEmpty { + HStack(spacing: 4) { + Image(systemName: "cpu") + .font(.system(size: 10)) + // Display a shortened version of the model name if it's too long (increased limit) + Text(modelName.count > 20 ? String(modelName.prefix(18)) + "..." : modelName) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule() + .fill(Color(.controlBackgroundColor))) + .overlay( + Capsule() + .stroke(Color(.separatorColor), lineWidth: 0.5) + ) + } + + // 4. AI Enhancement badge + if config.isAIEnhancementEnabled { + // Context Awareness badge (moved before AI Enhancement) + if config.useScreenCapture { + HStack(spacing: 4) { + Image(systemName: "camera.viewfinder") + .font(.system(size: 10)) + Text("Context Awareness") + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule() + .fill(Color(.controlBackgroundColor))) + .overlay( + Capsule() + .stroke(Color(.separatorColor), lineWidth: 0.5) + ) + } + + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 10)) + Text(selectedPrompt?.title ?? "AI") + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule() + .fill(Color.accentColor.opacity(0.1))) + .foregroundColor(.accentColor) + } + + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + } + } + .background(CardBackground(isSelected: isEditing)) + } + .buttonStyle(.plain) + } + + private var isSelected: Bool { + return isEditing + } +} + +// App Icon View Component +struct PowerModeAppIcon: View { + let bundleId: String + + var body: some View { + if let appUrl = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) { + Image(nsImage: NSWorkspace.shared.icon(forFile: appUrl.path)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + } else { + Image(systemName: "app.fill") + .font(.system(size: 14)) + .foregroundColor(.secondary) + .frame(width: 20, height: 20) + } + } +} + +struct AppGridItem: View { + let app: (url: URL, name: String, bundleId: String, icon: NSImage) + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Image(nsImage: app.icon) + .resizable() + .frame(width: 40, height: 40) + .cornerRadius(8) + .shadow(color: Color(NSColor.shadowColor).opacity(0.1), radius: 2, x: 0, y: 1) + Text(app.name) + .font(.system(size: 10)) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(height: 28) + } + .frame(width: 80, height: 80) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} diff --git a/VoiceInk/Services/AIEnhancementService.swift b/VoiceInk/Services/AIEnhancementService.swift index 696fe4b..d06377c 100644 --- a/VoiceInk/Services/AIEnhancementService.swift +++ b/VoiceInk/Services/AIEnhancementService.swift @@ -125,6 +125,10 @@ class AIEnhancementService: ObservableObject { } } + func getAIService() -> AIService? { + return aiService + } + var isConfigured: Bool { aiService.isAPIKeyValid } diff --git a/VoiceInk/Views/Common/CardBackground.swift b/VoiceInk/Views/Common/CardBackground.swift new file mode 100644 index 0000000..27a674c --- /dev/null +++ b/VoiceInk/Views/Common/CardBackground.swift @@ -0,0 +1,87 @@ +import SwiftUI + +// Style Constants for consistent styling across components +struct StyleConstants { + // Colors - Glassmorphism Style + static let cardGradient = LinearGradient( // Simulates frosted glass + gradient: Gradient(stops: [ + .init(color: Color(NSColor.windowBackgroundColor).opacity(0.6), location: 0.0), + .init(color: Color(NSColor.windowBackgroundColor).opacity(0.55), location: 0.70), // Hold start opacity longer + .init(color: Color(NSColor.windowBackgroundColor).opacity(0.3), location: 1.0) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let cardGradientSelected = LinearGradient( // Selected glass, accent tint extends further + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.3), location: 0.0), + .init(color: Color.accentColor.opacity(0.25), location: 0.70), // Accent tint held longer + .init(color: Color(NSColor.windowBackgroundColor).opacity(0.4), location: 1.0) // Blend to window bg at the end + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // Border Colors - Adaptive and subtle for glass effect + static let cardBorder = LinearGradient( + gradient: Gradient(colors: [ + Color(NSColor.quaternaryLabelColor).opacity(0.5), // Adaptive border color + Color(NSColor.quaternaryLabelColor).opacity(0.3) // Adaptive border color + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let cardBorderSelected = LinearGradient( + gradient: Gradient(colors: [ + Color.accentColor.opacity(0.4), + Color.accentColor.opacity(0.2) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // Shadows - Adaptive, soft and diffuse for a floating glass look + static let shadowDefault = Color(NSColor.shadowColor).opacity(0.1) + static let shadowSelected = Color(NSColor.shadowColor).opacity(0.15) + + // Corner Radius - Larger for a softer, glassy feel + static let cornerRadius: CGFloat = 16 + + // Button Style (Keeping this as is unless specified) + static let buttonGradient = LinearGradient( + colors: [Color.accentColor, Color.accentColor.opacity(0.8)], + startPoint: .leading, + endPoint: .trailing + ) +} + +// Reusable background component +struct CardBackground: View { + var isSelected: Bool + var cornerRadius: CGFloat = StyleConstants.cornerRadius + var useAccentGradientWhenSelected: Bool = false // This might need rethinking for pure glassmorphism + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill( + useAccentGradientWhenSelected && isSelected ? + StyleConstants.cardGradientSelected : + StyleConstants.cardGradient + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke( + isSelected ? StyleConstants.cardBorderSelected : StyleConstants.cardBorder, + lineWidth: 1.5 // Slightly thicker border for a defined glass edge + ) + ) + .shadow( + color: isSelected ? StyleConstants.shadowSelected : StyleConstants.shadowDefault, + radius: isSelected ? 15 : 10, // Larger radius for softer, more diffuse shadows + x: 0, + y: isSelected ? 8 : 5 // Slightly more y-offset for a lifted look + ) + } +} \ No newline at end of file diff --git a/VoiceInk/Views/ModelCardRowView.swift b/VoiceInk/Views/ModelCardRowView.swift index b341745..2b0bc1e 100644 --- a/VoiceInk/Views/ModelCardRowView.swift +++ b/VoiceInk/Views/ModelCardRowView.swift @@ -33,39 +33,7 @@ struct ModelCardRowView: View { actionSection } .padding(16) - .background( - RoundedRectangle(cornerRadius: 14) - .fill( - LinearGradient( - gradient: Gradient(colors: [ - Color(NSColor.controlBackgroundColor).opacity(1.0), - Color(NSColor.controlBackgroundColor).opacity(0.6) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke( - LinearGradient( - gradient: Gradient(colors: [ - Color.white.opacity(0.3), - Color.white.opacity(0.05) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: 1 - ) - ) - .shadow( - color: Color.primary.opacity(0.08), - radius: 8, - x: 0, - y: 4 - ) - ) + .background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent)) } // MARK: - Components diff --git a/VoiceInk/Views/PowerModeView.swift b/VoiceInk/Views/PowerModeView.swift deleted file mode 100644 index 02c17bd..0000000 --- a/VoiceInk/Views/PowerModeView.swift +++ /dev/null @@ -1,458 +0,0 @@ -import SwiftUI - -// Configuration Mode Enum -enum ConfigurationMode { - case add - case edit(PowerModeConfig) - case editDefault(PowerModeConfig) - - var isAdding: Bool { - if case .add = self { return true } - return false - } - - var isEditingDefault: Bool { - if case .editDefault = self { return true } - return false - } - - var title: String { - switch self { - case .add: return "Add Configuration" - case .editDefault: return "Edit Default Configuration" - case .edit: return "Edit Configuration" - } - } -} - -// Configuration Type -enum ConfigurationType { - case application - case website -} - -// Main Configuration Sheet -struct ConfigurationSheet: View { - let mode: ConfigurationMode - @Binding var isPresented: Bool - let powerModeManager: PowerModeManager - @EnvironmentObject var enhancementService: AIEnhancementService - - // State for configuration - @State private var configurationType: ConfigurationType = .application - @State private var selectedAppURL: URL? - @State private var isAIEnhancementEnabled: Bool - @State private var selectedPromptId: UUID? - @State private var installedApps: [(url: URL, name: String, bundleId: String, icon: NSImage)] = [] - @State private var searchText = "" - - // Website configuration state - @State private var websiteURL: String = "" - @State private var websiteName: String = "" - - private var filteredApps: [(url: URL, name: String, bundleId: String, icon: NSImage)] { - if searchText.isEmpty { - return installedApps - } - return installedApps.filter { app in - app.name.localizedCaseInsensitiveContains(searchText) || - app.bundleId.localizedCaseInsensitiveContains(searchText) - } - } - - init(mode: ConfigurationMode, isPresented: Binding, powerModeManager: PowerModeManager) { - self.mode = mode - self._isPresented = isPresented - self.powerModeManager = powerModeManager - - switch mode { - case .add: - _isAIEnhancementEnabled = State(initialValue: true) - _selectedPromptId = State(initialValue: nil) - case .edit(let config), .editDefault(let config): - _isAIEnhancementEnabled = State(initialValue: config.isAIEnhancementEnabled) - _selectedPromptId = State(initialValue: config.selectedPrompt.flatMap { UUID(uuidString: $0) }) - if case .edit(let config) = mode { - // Initialize website configuration if it exists - if let urlConfig = config.urlConfigs?.first { - _configurationType = State(initialValue: .website) - _websiteURL = State(initialValue: urlConfig.url) - _websiteName = State(initialValue: config.appName) - } else { - _configurationType = State(initialValue: .application) - _selectedAppURL = State(initialValue: NSWorkspace.shared.urlForApplication(withBundleIdentifier: config.bundleIdentifier)) - } - } - } - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text(mode.title) - .font(.headline) - Spacer() - } - .padding() - - Divider() - - if mode.isAdding { - // Configuration Type Selector - Picker("Configuration Type", selection: $configurationType) { - Text("Application").tag(ConfigurationType.application) - Text("Website").tag(ConfigurationType.website) - } - - .padding() - - if configurationType == .application { - // Search bar - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search applications...", text: $searchText) - .textFieldStyle(PlainTextFieldStyle()) - if !searchText.isEmpty { - Button(action: { searchText = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - .buttonStyle(PlainButtonStyle()) - } - } - .padding(8) - .background(Color(.windowBackgroundColor).opacity(0.4)) - .cornerRadius(8) - .padding() - - // App Grid - ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 100, maximum: 120), spacing: 16)], spacing: 16) { - ForEach(filteredApps.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }), id: \.bundleId) { app in - AppGridItem( - app: app, - isSelected: app.url == selectedAppURL, - action: { selectedAppURL = app.url } - ) - } - } - .padding() - } - } else { - // Website Configuration - VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - Text("Website Name") - .font(.headline) - TextField("Enter website name", text: $websiteName) - .textFieldStyle(.roundedBorder) - } - - VStack(alignment: .leading, spacing: 8) { - Text("Website URL") - .font(.headline) - TextField("Enter website URL (e.g., google.com)", text: $websiteURL) - .textFieldStyle(.roundedBorder) - } - } - .padding() - } - } - - // Configuration Form - if let config = getConfigForForm() { - if let appURL = !mode.isEditingDefault ? NSWorkspace.shared.urlForApplication(withBundleIdentifier: config.bundleIdentifier) : nil { - AppConfigurationFormView( - appName: config.appName, - appIcon: NSWorkspace.shared.icon(forFile: appURL.path), - isDefaultConfig: mode.isEditingDefault, - isAIEnhancementEnabled: $isAIEnhancementEnabled, - selectedPromptId: $selectedPromptId - ) - } else { - AppConfigurationFormView( - appName: nil, - appIcon: nil, - isDefaultConfig: mode.isEditingDefault, - isAIEnhancementEnabled: $isAIEnhancementEnabled, - selectedPromptId: $selectedPromptId - ) - } - } - - Divider() - - // Bottom buttons - HStack { - Button("Cancel") { - isPresented = false - } - .keyboardShortcut(.escape, modifiers: []) - - Spacer() - - Button(mode.isAdding ? "Add" : "Save") { - saveConfiguration() - } - .keyboardShortcut(.return, modifiers: []) - .disabled(mode.isAdding && !canSave) - } - .padding() - } - .frame(width: 600) - .frame(maxHeight: mode.isAdding ? 700 : 600) - .onAppear { - print("🔍 ConfigurationSheet appeared - Mode: \(mode)") - if mode.isAdding { - print("🔍 Loading installed apps...") - loadInstalledApps() - } - } - } - - private var canSave: Bool { - if configurationType == .application { - return selectedAppURL != nil - } else { - return !websiteURL.isEmpty && !websiteName.isEmpty - } - } - - private func getConfigForForm() -> PowerModeConfig? { - switch mode { - case .add: - if configurationType == .application { - guard let url = selectedAppURL, - let bundle = Bundle(url: url), - let bundleId = bundle.bundleIdentifier else { return nil } - - let appName = bundle.infoDictionary?["CFBundleName"] as? String ?? - bundle.infoDictionary?["CFBundleDisplayName"] as? String ?? - "Unknown App" - - return PowerModeConfig( - bundleIdentifier: bundleId, - appName: appName, - isAIEnhancementEnabled: isAIEnhancementEnabled, - selectedPrompt: selectedPromptId?.uuidString - ) - } else { - // Create a special PowerModeConfig for websites - let urlConfig = URLConfig(url: websiteURL, promptId: selectedPromptId?.uuidString) - return PowerModeConfig( - bundleIdentifier: "website.\(UUID().uuidString)", - appName: websiteName, - isAIEnhancementEnabled: isAIEnhancementEnabled, - selectedPrompt: selectedPromptId?.uuidString, - urlConfigs: [urlConfig] - ) - } - case .edit(let config), .editDefault(let config): - return config - } - } - - private func loadInstalledApps() { - // Get both user-installed and system applications - let userAppURLs = FileManager.default.urls(for: .applicationDirectory, in: .localDomainMask) - let systemAppURLs = FileManager.default.urls(for: .applicationDirectory, in: .systemDomainMask) - let allAppURLs = userAppURLs + systemAppURLs - - let apps = allAppURLs.flatMap { baseURL -> [URL] in - let enumerator = FileManager.default.enumerator( - at: baseURL, - includingPropertiesForKeys: [.isApplicationKey], - options: [.skipsHiddenFiles, .skipsPackageDescendants] - ) - - return enumerator?.compactMap { item -> URL? in - guard let url = item as? URL, - url.pathExtension == "app" else { return nil } - return url - } ?? [] - } - - installedApps = apps.compactMap { url in - guard let bundle = Bundle(url: url), - let bundleId = bundle.bundleIdentifier, - let name = (bundle.infoDictionary?["CFBundleName"] as? String) ?? - (bundle.infoDictionary?["CFBundleDisplayName"] as? String) else { - return nil - } - - let icon = NSWorkspace.shared.icon(forFile: url.path) - return (url: url, name: name, bundleId: bundleId, icon: icon) - } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - } - - private func saveConfiguration() { - if isAIEnhancementEnabled && selectedPromptId == nil { - selectedPromptId = enhancementService.allPrompts.first?.id - } - - switch mode { - case .add: - if let config = getConfigForForm() { - powerModeManager.addConfiguration(config) - } - case .edit(let config), .editDefault(let config): - var updatedConfig = config - updatedConfig.isAIEnhancementEnabled = isAIEnhancementEnabled - updatedConfig.selectedPrompt = selectedPromptId?.uuidString - - // Update URL configurations if this is a website config - if configurationType == .website { - let urlConfig = URLConfig(url: cleanURL(websiteURL), promptId: selectedPromptId?.uuidString) - updatedConfig.urlConfigs = [urlConfig] - updatedConfig.appName = websiteName - } - - powerModeManager.updateConfiguration(updatedConfig) - } - - isPresented = false - } - - private func cleanURL(_ url: String) -> String { - var cleanedURL = url.lowercased() - .replacingOccurrences(of: "https://", with: "") - .replacingOccurrences(of: "http://", with: "") - .replacingOccurrences(of: "www.", with: "") - - // Remove trailing slash if present - if cleanedURL.last == "/" { - cleanedURL.removeLast() - } - - return cleanedURL - } -} - -// Main View -struct PowerModeView: View { - @StateObject private var powerModeManager = PowerModeManager.shared - @EnvironmentObject private var enhancementService: AIEnhancementService - @State private var showingConfigSheet = false { - didSet { - print("🔍 showingConfigSheet changed to: \(showingConfigSheet)") - } - } - @State private var configurationMode: ConfigurationMode? { - didSet { - print("🔍 configurationMode changed to: \(String(describing: configurationMode))") - } - } - - var body: some View { - ScrollView { - VStack(spacing: 32) { - // Power Mode Toggle Section - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Enable Power Mode") - .font(.headline) - - InfoTip( - title: "Power Mode", - message: "Create app-specific or URL-specific configurations that automatically apply when using those apps or websites.", - learnMoreURL: "https://www.youtube.com/watch?v=cEepexxgf6Y&t=10s" - ) - - Spacer() - Toggle("", isOn: $powerModeManager.isPowerModeEnabled) - .toggleStyle(SwitchToggleStyle(tint: .blue)) - .labelsHidden() - .scaleEffect(1.2) - .onChange(of: powerModeManager.isPowerModeEnabled) { oldValue, newValue in - powerModeManager.savePowerModeEnabled() - } - } - } - .padding(.horizontal) - - if powerModeManager.isPowerModeEnabled { - // Default Configuration Section - VStack(alignment: .leading, spacing: 16) { - Text("Default Configuration") - .font(.headline) - - ConfiguredAppRow( - config: powerModeManager.defaultConfig, - isEditing: configurationMode?.isEditingDefault ?? false, - action: { - configurationMode = .editDefault(powerModeManager.defaultConfig) - showingConfigSheet = true - } - ) - .background(RoundedRectangle(cornerRadius: 8) - .fill(Color(.windowBackgroundColor).opacity(0.4))) - .overlay(RoundedRectangle(cornerRadius: 8) - .stroke(Color.accentColor.opacity(0.2), lineWidth: 1)) - } - .padding(.horizontal) - - // Apps Section - VStack(spacing: 16) { - if powerModeManager.configurations.isEmpty { - PowerModeEmptyStateView( - showAddModal: $showingConfigSheet, - configMode: $configurationMode - ) - } else { - Text("Power Mode Configurations") - .font(.headline) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - - ConfiguredAppsGrid(powerModeManager: powerModeManager) - - Button(action: { - print("🔍 Add button clicked - Setting config mode and showing sheet") - configurationMode = .add - print("🔍 Configuration mode set to: \(String(describing: configurationMode))") - showingConfigSheet = true - print("🔍 showingConfigSheet set to: \(showingConfigSheet)") - }) { - HStack(spacing: 6) { - Image(systemName: "plus") - .font(.system(size: 12, weight: .semibold)) - Text("Add New Mode") - .font(.system(size: 13, weight: .medium)) - } - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - .tint(Color(NSColor.controlAccentColor)) - .frame(maxWidth: .infinity, alignment: .center) - .help("Add a new mode") - .padding(.top, 12) - } - } - } - } - .padding(24) - } - .background(Color(NSColor.controlBackgroundColor)) - .sheet(isPresented: $showingConfigSheet, onDismiss: { - print("🔍 Sheet dismissed - Clearing configuration mode") - configurationMode = nil - }) { - Group { - if let mode = configurationMode { - ConfigurationSheet( - mode: mode, - isPresented: $showingConfigSheet, - powerModeManager: powerModeManager - ) - .environmentObject(enhancementService) - .onAppear { - print("🔍 Creating ConfigurationSheet with mode: \(mode)") - } - } - } - } - } -} diff --git a/VoiceInk/Views/PowerModeViewComponents.swift b/VoiceInk/Views/PowerModeViewComponents.swift deleted file mode 100644 index 9539d1c..0000000 --- a/VoiceInk/Views/PowerModeViewComponents.swift +++ /dev/null @@ -1,310 +0,0 @@ -import SwiftUI -// Supporting Views -struct PowerModeEmptyStateView: View { - @Binding var showAddModal: Bool - @Binding var configMode: ConfigurationMode? - - var body: some View { - VStack(spacing: 16) { - Image(systemName: "bolt.circle.fill") - .font(.system(size: 48)) - .foregroundColor(.secondary) - - Text("No Applications Configured") - .font(.title2) - .fontWeight(.semibold) - - Text("Add applications to customize their AI enhancement settings.") - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - Button(action: { - print("🔍 Empty state Add Application button clicked") - configMode = .add - print("🔍 Configuration mode set to: \(String(describing: configMode))") - showAddModal = true - print("🔍 Empty state showAddModal set to: \(showAddModal)") - }) { - Label("Add Application", systemImage: "plus.circle.fill") - .font(.headline) - } - .buttonStyle(.borderedProminent) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - } -} - -struct ConfiguredAppsGrid: View { - @ObservedObject var powerModeManager: PowerModeManager - @EnvironmentObject var enhancementService: AIEnhancementService - @State private var editingConfig: PowerModeConfig? - @State private var showingConfigSheet = false - - var body: some View { - ScrollView { - VStack(spacing: 8) { - ForEach(powerModeManager.configurations.sorted(by: { $0.appName.localizedCaseInsensitiveCompare($1.appName) == .orderedAscending })) { config in - ConfiguredAppRow( - config: config, - isEditing: editingConfig?.id == config.id, - action: { - editingConfig = config - showingConfigSheet = true - } - ) - .contextMenu { - Button(action: { - editingConfig = config - showingConfigSheet = true - }) { - Label("Edit", systemImage: "pencil") - } - Button(role: .destructive, action: { - powerModeManager.removeConfiguration(for: config.bundleIdentifier) - }) { - Label("Remove", systemImage: "trash") - } - } - } - } - .padding() - } - .sheet(isPresented: $showingConfigSheet, onDismiss: { editingConfig = nil }) { - if let config = editingConfig { - ConfigurationSheet( - mode: .edit(config), - isPresented: $showingConfigSheet, - powerModeManager: powerModeManager - ) - .environmentObject(enhancementService) - } - } - } -} - -struct ConfiguredAppRow: View { - let config: PowerModeConfig - let isEditing: Bool - let action: () -> Void - @EnvironmentObject var enhancementService: AIEnhancementService - - private var selectedPrompt: CustomPrompt? { - guard let promptId = config.selectedPrompt, - let uuid = UUID(uuidString: promptId) else { return nil } - return enhancementService.allPrompts.first { $0.id == uuid } - } - - private var isWebsiteConfig: Bool { - return config.urlConfigs != nil && !config.urlConfigs!.isEmpty - } - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - // Icon - if isWebsiteConfig { - Image(systemName: "globe") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .foregroundColor(.accentColor) - } else if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: config.bundleIdentifier) { - Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path)) - .resizable() - .frame(width: 32, height: 32) - } - - // Info - VStack(alignment: .leading, spacing: 2) { - Text(config.appName) - .font(.headline) - if isWebsiteConfig { - if let urlConfig = config.urlConfigs?.first { - Text(urlConfig.url) - .font(.caption) - .foregroundColor(.secondary) - } - } else { - Text(config.bundleIdentifier) - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - - HStack(spacing: 8) { - HStack(spacing: 4) { - Image(systemName: config.isAIEnhancementEnabled ? "checkmark.circle.fill" : "circle") - .foregroundColor(config.isAIEnhancementEnabled ? .accentColor : .secondary) - .font(.system(size: 14)) - Text("AI Enhancement") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(config.isAIEnhancementEnabled ? .accentColor : .secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 6) - .fill(config.isAIEnhancementEnabled ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.1))) - - if config.isAIEnhancementEnabled { - if let prompt = selectedPrompt { - HStack(spacing: 4) { - Image(systemName: prompt.icon.rawValue) - .foregroundColor(.accentColor) - .font(.system(size: 14)) - Text(prompt.title) - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 6) - .fill(Color.accentColor.opacity(0.1))) - } else { - Text("No Prompt") - .font(.system(size: 12)) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 6) - .fill(Color.secondary.opacity(0.1))) - } - } - } - } - .contentShape(Rectangle()) - .padding(12) - .background(RoundedRectangle(cornerRadius: 8) - .fill(isEditing ? Color.accentColor.opacity(0.1) : Color(.windowBackgroundColor).opacity(0.4))) - .overlay(RoundedRectangle(cornerRadius: 8) - .stroke(isEditing ? Color.accentColor : Color.clear, lineWidth: 1)) - } - .buttonStyle(.plain) - } -} - -struct AppConfigurationFormView: View { - let appName: String? - let appIcon: NSImage? - let isDefaultConfig: Bool - @Binding var isAIEnhancementEnabled: Bool - @Binding var selectedPromptId: UUID? - @EnvironmentObject var enhancementService: AIEnhancementService - - var body: some View { - VStack(spacing: 20) { - VStack(spacing: 16) { - if !isDefaultConfig { - if let appIcon = appIcon { - HStack { - Image(nsImage: appIcon) - .resizable() - .frame(width: 32, height: 32) - Text(appName ?? "") - .font(.headline) - Spacer() - } - } - } else { - HStack { - Image(systemName: "gearshape.fill") - .font(.system(size: 24)) - .foregroundColor(.accentColor) - Text("Default Settings") - .font(.headline) - Spacer() - } - - Text("These settings will be applied to all applications that don't have specific configurations.") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.bottom, 8) - } - - Toggle("AI Enhancement", isOn: $isAIEnhancementEnabled) - } - .padding(.horizontal) - - if isAIEnhancementEnabled { - Divider() - - VStack(alignment: .leading) { - Text("Select Prompt") - .font(.headline) - .padding(.horizontal) - - let columns = [ - GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 36) - ] - - LazyVGrid(columns: columns, spacing: 24) { - ForEach(enhancementService.allPrompts) { prompt in - prompt.promptIcon( - isSelected: selectedPromptId == prompt.id, - onTap: { selectedPromptId = prompt.id } - ) - } - } - .padding(.vertical, 12) - .padding(.horizontal, 16) - } - } - } - .padding(.vertical) - } -} - -struct AppGridItem: View { - let app: (url: URL, name: String, bundleId: String, icon: NSImage) - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 8) { - Image(nsImage: app.icon) - .resizable() - .frame(width: 48, height: 48) - Text(app.name) - .font(.system(size: 12)) - .lineLimit(2) - .multilineTextAlignment(.center) - .frame(height: 32) - } - .frame(width: 100) - .padding(8) - .background(RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)) - .overlay(RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 1)) - } - .buttonStyle(.plain) - } -} - -// New component for feature highlights -struct FeatureHighlight: View { - let icon: String - let title: String - let description: String - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.blue) - Text(title) - .font(.system(size: 13, weight: .semibold)) - } - - Text(description) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .lineLimit(2) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index b3d6412..38e92ac 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -78,6 +78,7 @@ struct VoiceInkApp: App { // Configure ActiveWindowService with enhancementService let activeWindowService = ActiveWindowService.shared activeWindowService.configure(with: enhancementService) + activeWindowService.configureWhisperState(whisperState) _activeWindowService = StateObject(wrappedValue: activeWindowService) } diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index f9ad0f2..7349a32 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -153,9 +153,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { // Clean up any old temporary file first self.recordedFile = file - // --- Start concurrent window config task immediately --- - async let windowConfigTask: () = ActiveWindowService.shared.applyConfigurationForCurrentApp() - try await self.recorder.startRecording(toOutputFile: file) self.logger.notice("✅ Audio engine started successfully.") @@ -163,27 +160,23 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { self.isRecording = true self.isVisualizerActive = true } + + await ActiveWindowService.shared.applyConfigurationForCurrentApp() - Task { - if let currentModel = await self.currentModel, await self.whisperContext == nil { - do { - try await self.loadModel(currentModel) - } catch { - self.logger.error("❌ Model loading failed: \(error.localizedDescription)") - } + if let currentModel = await self.currentModel, await self.whisperContext == nil { + do { + try await self.loadModel(currentModel) + } catch { + self.logger.error("❌ Model loading failed: \(error.localizedDescription)") } } - Task { - if let enhancementService = self.enhancementService, - enhancementService.isEnhancementEnabled && - enhancementService.useScreenCaptureContext { - await enhancementService.captureScreenContext() - } + if let enhancementService = self.enhancementService, + enhancementService.isEnhancementEnabled && + enhancementService.useScreenCaptureContext { + await enhancementService.captureScreenContext() } - await windowConfigTask - } catch { self.logger.error("❌ Failed to start recording: \(error.localizedDescription)") await MainActor.run {