diff --git a/VoiceInk/PowerMode/EmojiManager.swift b/VoiceInk/PowerMode/EmojiManager.swift new file mode 100644 index 0000000..68cc3b8 --- /dev/null +++ b/VoiceInk/PowerMode/EmojiManager.swift @@ -0,0 +1,53 @@ +import Foundation + +class EmojiManager: ObservableObject { + static let shared = EmojiManager() + + private let defaultEmojis = ["🏢", "🏠", "💼", "🎮", "📱", "📺", "🎵", "📚", "✏️", "🎨", "🧠", "⚙️", "💻", "🌐", "📝", "📊", "🔍", "💬", "📈", "🔧"] + private let customEmojisKey = "userAddedEmojis" + + @Published var customEmojis: [String] = [] + + private init() { + loadCustomEmojis() + } + + var allEmojis: [String] { + return defaultEmojis + customEmojis + } + + func addCustomEmoji(_ emoji: String) -> Bool { + let trimmedEmoji = emoji.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedEmoji.isEmpty, !allEmojis.contains(trimmedEmoji) else { + return false + } + + customEmojis.append(trimmedEmoji) + saveCustomEmojis() + return true + } + + private func loadCustomEmojis() { + if let savedEmojis = UserDefaults.standard.array(forKey: customEmojisKey) as? [String] { + customEmojis = savedEmojis + } + } + + private func saveCustomEmojis() { + UserDefaults.standard.set(customEmojis, forKey: customEmojisKey) + } + + func removeCustomEmoji(_ emoji: String) -> Bool { + if let index = customEmojis.firstIndex(of: emoji) { + customEmojis.remove(at: index) + saveCustomEmojis() + return true + } + return false + } + + func isCustomEmoji(_ emoji: String) -> Bool { + return customEmojis.contains(emoji) + } +} \ No newline at end of file diff --git a/VoiceInk/PowerMode/EmojiPickerView.swift b/VoiceInk/PowerMode/EmojiPickerView.swift new file mode 100644 index 0000000..0a728bb --- /dev/null +++ b/VoiceInk/PowerMode/EmojiPickerView.swift @@ -0,0 +1,237 @@ +import SwiftUI + +struct EmojiPickerView: View { + @StateObject private var emojiManager = EmojiManager.shared + @Binding var selectedEmoji: String + @Binding var isPresented: Bool + @State private var newEmojiText: String = "" + @State private var isAddingCustomEmoji: Bool = false + @FocusState private var isEmojiTextFieldFocused: Bool + @State private var inputFeedbackMessage: String = "" + @State private var showingEmojiInUseAlert = false + @State private var emojiForAlert: String? = nil + private let columns: [GridItem] = [GridItem(.adaptive(minimum: 44), spacing: 10)] + + var body: some View { + VStack(spacing: 12) { + ScrollView { + LazyVGrid(columns: columns, spacing: 10) { + ForEach(emojiManager.allEmojis, id: \.self) { emoji in + EmojiButton( + emoji: emoji, + isSelected: selectedEmoji == emoji, + isCustom: emojiManager.isCustomEmoji(emoji), + removeAction: { + attemptToRemoveCustomEmoji(emoji) + } + ) { + selectedEmoji = emoji + inputFeedbackMessage = "" + isPresented = false + } + } + + AddEmojiButton { + isAddingCustomEmoji.toggle() + newEmojiText = "" + inputFeedbackMessage = "" + if isAddingCustomEmoji { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isEmojiTextFieldFocused = true + } + } + } + } + } + .frame(maxHeight: 200) + + if isAddingCustomEmoji { + VStack(spacing: 8) { + HStack(spacing: 8) { + TextField("➕", text: $newEmojiText) + .textFieldStyle(.roundedBorder) + .font(.title2) + .multilineTextAlignment(.center) + .frame(maxWidth: 70) + .focused($isEmojiTextFieldFocused) + .onChange(of: newEmojiText) { _, newValue in + inputFeedbackMessage = "" + let cleaned = newValue.firstValidEmojiCharacter() + if newEmojiText != cleaned { + newEmojiText = cleaned + } + if !newEmojiText.isEmpty && emojiManager.allEmojis.contains(newEmojiText) { + inputFeedbackMessage = "Emoji already exists!" + } else if !newEmojiText.isEmpty && !newEmojiText.isValidEmoji { + inputFeedbackMessage = "Invalid emoji." + } else { + inputFeedbackMessage = "" + } + } + .onSubmit(attemptAddCustomEmoji) + + Button("Add") { + attemptAddCustomEmoji() + } + .buttonStyle(.borderedProminent) + .disabled(newEmojiText.isEmpty || !newEmojiText.isValidEmoji || emojiManager.allEmojis.contains(newEmojiText)) + + Button("Cancel") { + isAddingCustomEmoji = false + newEmojiText = "" + inputFeedbackMessage = "" + } + .buttonStyle(.bordered) + } + if !inputFeedbackMessage.isEmpty { + Text(inputFeedbackMessage) + .font(.caption) + .foregroundColor(inputFeedbackMessage == "Emoji already exists!" || inputFeedbackMessage == "Invalid emoji." ? .red : .secondary) + .transition(.opacity) + } + Text("Tip: Use ⌃⌘Space for emoji keyboard.") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.top, 2) + } + .padding(.horizontal) + .padding(.bottom, 5) + } + } + .padding() + .background(.regularMaterial) + .frame(minWidth: 260, idealWidth: 300, maxWidth: 320, minHeight: 150, idealHeight: 280, maxHeight: 350) + .alert("Emoji in Use", isPresented: $showingEmojiInUseAlert, presenting: emojiForAlert) { emojiStr in + Button("OK", role: .cancel) { } + } message: { emojiStr in + Text("The emoji \"\(emojiStr)\" is currently used by one or more Power Modes and cannot be removed.") + } + } + + private func attemptAddCustomEmoji() { + let trimmedEmoji = newEmojiText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedEmoji.isEmpty else { + inputFeedbackMessage = "Emoji cannot be empty." + return + } + guard trimmedEmoji.isValidEmoji else { + inputFeedbackMessage = "Invalid emoji character." + return + } + guard !emojiManager.allEmojis.contains(trimmedEmoji) else { + inputFeedbackMessage = "Emoji already exists!" + return + } + + if emojiManager.addCustomEmoji(trimmedEmoji) { + selectedEmoji = trimmedEmoji + inputFeedbackMessage = "" + isAddingCustomEmoji = false + newEmojiText = "" + } else { + inputFeedbackMessage = "Could not add emoji." + } + } + + private func attemptToRemoveCustomEmoji(_ emojiToRemove: String) { + guard emojiManager.isCustomEmoji(emojiToRemove) else { return } + + if PowerModeManager.shared.isEmojiInUse(emojiToRemove) { + emojiForAlert = emojiToRemove + showingEmojiInUseAlert = true + } else { + if emojiManager.removeCustomEmoji(emojiToRemove) { + if selectedEmoji == emojiToRemove { + } + } + } + } +} + +private struct EmojiButton: View { + let emoji: String + let isSelected: Bool + let isCustom: Bool + let removeAction: () -> Void + let selectAction: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Button(action: selectAction) { + Text(emoji) + .font(.largeTitle) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(isSelected ? Color.accentColor.opacity(0.25) : Color.clear) + ) + .overlay( + Circle() + .strokeBorder(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) + ) + } + .buttonStyle(.plain) + + if isCustom { + Button(action: removeAction) { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color.red) + .font(.caption2) + .background(Circle().fill(Color.white.opacity(0.8))) + } + .buttonStyle(.borderless) + .offset(x: 6, y: -6) + } + } + } +} + +private struct AddEmojiButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label("Add Emoji", systemImage: "plus.circle.fill") + .font(.title2) + .labelStyle(.iconOnly) + .foregroundColor(.accentColor) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(Color.secondary.opacity(0.1)) + ) + .overlay( + Circle() + .strokeBorder(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .help("Add custom emoji") + } +} + +extension String { + var isValidEmoji: Bool { + guard !self.isEmpty else { return false } + return self.count == 1 && self.unicodeScalars.first?.properties.isEmoji ?? false + } + + func firstValidEmojiCharacter() -> String { + return self.filter { $0.unicodeScalars.allSatisfy { $0.properties.isEmoji } }.prefix(1).map(String.init).joined() + } +} + +#if DEBUG +struct EmojiPickerView_Previews: PreviewProvider { + static var previews: some View { + EmojiPickerView( + selectedEmoji: .constant("😀"), + isPresented: .constant(true) + ) + .environmentObject(EmojiManager.shared) + } +} +#endif + + \ No newline at end of file diff --git a/VoiceInk/PowerMode/PowerModeConfig.swift b/VoiceInk/PowerMode/PowerModeConfig.swift index e9c299d..ae1b994 100644 --- a/VoiceInk/PowerMode/PowerModeConfig.swift +++ b/VoiceInk/PowerMode/PowerModeConfig.swift @@ -7,12 +7,12 @@ struct PowerModeConfig: Codable, Identifiable, Equatable { 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 selectedPrompt: String? + var selectedWhisperModel: String? + var selectedLanguage: String? 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") + var selectedAIProvider: String? + var selectedAIModel: String? init(id: UUID = UUID(), name: String, emoji: String, appConfigs: [AppConfig]? = nil, urlConfigs: [URLConfig]? = nil, isAIEnhancementEnabled: Bool, selectedPrompt: String? = nil, @@ -28,8 +28,6 @@ struct PowerModeConfig: Codable, Identifiable, Equatable { 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" } @@ -270,4 +268,11 @@ class PowerModeManager: ObservableObject { func getAllAvailableConfigurations() -> [PowerModeConfig] { return [defaultConfig] + configurations } + + func isEmojiInUse(_ emoji: String) -> Bool { + if defaultConfig.emoji == emoji { + return true + } + return configurations.contains { $0.emoji == emoji } + } } \ No newline at end of file diff --git a/VoiceInk/PowerMode/PowerModeConfigView.swift b/VoiceInk/PowerMode/PowerModeConfigView.swift index ae97dd3..6ac9f59 100644 --- a/VoiceInk/PowerMode/PowerModeConfigView.swift +++ b/VoiceInk/PowerMode/PowerModeConfigView.swift @@ -157,6 +157,12 @@ struct ConfigurationView: View { .buttonStyle(.plain) .disabled(mode.isEditingDefault) .opacity(mode.isEditingDefault ? 0.5 : 1) + .popover(isPresented: $isShowingEmojiPicker, arrowEdge: .bottom) { + EmojiPickerView( + selectedEmoji: $selectedEmoji, + isPresented: $isShowingEmojiPicker + ) + } TextField("Name your power mode", text: $configName) .font(.system(size: 18, weight: .bold)) @@ -176,31 +182,14 @@ struct ConfigurationView: View { .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) - } + // Enhanced Emoji Picker with Custom Emoji Support + // if isShowingEmojiPicker { // <<< This conditional block will be removed + // EmojiPickerView( + // selectedEmoji: $selectedEmoji, + // isPresented: $isShowingEmojiPicker + // ) + // .padding(.horizontal) + // } // SECTION 1: TRIGGERS if !mode.isEditingDefault { diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index 205bede..216b54e 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -70,10 +70,10 @@ class Recorder: ObservableObject { if deviceID != 0 { do { try await configureAudioSession(with: deviceID) - try? await Task.sleep(nanoseconds: 200_000_000) + try? await Task.sleep(nanoseconds: 50_000_000) } catch { logger.warning("⚠️ Failed to configure audio session for device \(deviceID), attempting to continue: \(error.localizedDescription)") - try? await Task.sleep(nanoseconds: 100_000_000) + try? await Task.sleep(nanoseconds: 50_000_000) } }