feat: better super whisper-like power mode with improved UX

This commit is contained in:
Beingpax 2025-05-24 12:38:30 +05:45
parent ae5a8496cf
commit 8dc622e077
17 changed files with 2007 additions and 1001 deletions

View File

@ -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 = (
);

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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<String?>(
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<String?>(
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<AIProvider>(
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<String>(
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()
}
}

View File

@ -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<Bool>
) -> 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.")
}
}
)
}
}

View File

@ -0,0 +1,261 @@
import SwiftUI
extension View {
func placeholder<Content: View>(
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)
}
}

View File

@ -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)
}
}

View File

@ -125,6 +125,10 @@ class AIEnhancementService: ObservableObject {
}
}
func getAIService() -> AIService? {
return aiService
}
var isConfigured: Bool {
aiService.isAPIKeyValid
}

View File

@ -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
)
}
}

View File

@ -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

View File

@ -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<Bool>, 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)")
}
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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 {