feat: better super whisper-like power mode with improved UX
This commit is contained in:
parent
ae5a8496cf
commit
8dc622e077
@ -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 = (
|
||||
);
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
VoiceInk/PowerMode/AppPicker.swift
Normal file
70
VoiceInk/PowerMode/AppPicker.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
240
VoiceInk/PowerMode/PowerModeConfig.swift
Normal file
240
VoiceInk/PowerMode/PowerModeConfig.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
747
VoiceInk/PowerMode/PowerModeConfigView.swift
Normal file
747
VoiceInk/PowerMode/PowerModeConfigView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
134
VoiceInk/PowerMode/PowerModeValidator.swift
Normal file
134
VoiceInk/PowerMode/PowerModeValidator.swift
Normal 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.")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
261
VoiceInk/PowerMode/PowerModeView.swift
Normal file
261
VoiceInk/PowerMode/PowerModeView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
392
VoiceInk/PowerMode/PowerModeViewComponents.swift
Normal file
392
VoiceInk/PowerMode/PowerModeViewComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -125,6 +125,10 @@ class AIEnhancementService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func getAIService() -> AIService? {
|
||||
return aiService
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
aiService.isAPIKeyValid
|
||||
}
|
||||
|
||||
87
VoiceInk/Views/Common/CardBackground.swift
Normal file
87
VoiceInk/Views/Common/CardBackground.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user