feat: Add support for selecting custom emojis for Power Mode
This commit is contained in:
parent
eb46afeb72
commit
8024146b61
53
VoiceInk/PowerMode/EmojiManager.swift
Normal file
53
VoiceInk/PowerMode/EmojiManager.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
237
VoiceInk/PowerMode/EmojiPickerView.swift
Normal file
237
VoiceInk/PowerMode/EmojiPickerView.swift
Normal file
@ -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
|
||||||
|
|
||||||
|
|
||||||
@ -7,12 +7,12 @@ struct PowerModeConfig: Codable, Identifiable, Equatable {
|
|||||||
var appConfigs: [AppConfig]?
|
var appConfigs: [AppConfig]?
|
||||||
var urlConfigs: [URLConfig]?
|
var urlConfigs: [URLConfig]?
|
||||||
var isAIEnhancementEnabled: Bool
|
var isAIEnhancementEnabled: Bool
|
||||||
var selectedPrompt: String? // UUID string of the selected prompt
|
var selectedPrompt: String?
|
||||||
var selectedWhisperModel: String? // Name of the selected Whisper model
|
var selectedWhisperModel: String?
|
||||||
var selectedLanguage: String? // Language code (e.g., "en", "fr")
|
var selectedLanguage: String?
|
||||||
var useScreenCapture: Bool
|
var useScreenCapture: Bool
|
||||||
var selectedAIProvider: String? // AI provider name (e.g., "OpenAI", "Gemini")
|
var selectedAIProvider: String?
|
||||||
var selectedAIModel: String? // AI model name (e.g., "gpt-4", "gemini-1.5-pro")
|
var selectedAIModel: String?
|
||||||
|
|
||||||
init(id: UUID = UUID(), name: String, emoji: String, appConfigs: [AppConfig]? = nil,
|
init(id: UUID = UUID(), name: String, emoji: String, appConfigs: [AppConfig]? = nil,
|
||||||
urlConfigs: [URLConfig]? = nil, isAIEnhancementEnabled: Bool, selectedPrompt: String? = nil,
|
urlConfigs: [URLConfig]? = nil, isAIEnhancementEnabled: Bool, selectedPrompt: String? = nil,
|
||||||
@ -28,8 +28,6 @@ struct PowerModeConfig: Codable, Identifiable, Equatable {
|
|||||||
self.useScreenCapture = useScreenCapture
|
self.useScreenCapture = useScreenCapture
|
||||||
self.selectedAIProvider = selectedAIProvider ?? UserDefaults.standard.string(forKey: "selectedAIProvider")
|
self.selectedAIProvider = selectedAIProvider ?? UserDefaults.standard.string(forKey: "selectedAIProvider")
|
||||||
self.selectedAIModel = selectedAIModel
|
self.selectedAIModel = selectedAIModel
|
||||||
|
|
||||||
// Use provided values or get from UserDefaults if nil
|
|
||||||
self.selectedWhisperModel = selectedWhisperModel ?? UserDefaults.standard.string(forKey: "CurrentModel")
|
self.selectedWhisperModel = selectedWhisperModel ?? UserDefaults.standard.string(forKey: "CurrentModel")
|
||||||
self.selectedLanguage = selectedLanguage ?? UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "en"
|
self.selectedLanguage = selectedLanguage ?? UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "en"
|
||||||
}
|
}
|
||||||
@ -270,4 +268,11 @@ class PowerModeManager: ObservableObject {
|
|||||||
func getAllAvailableConfigurations() -> [PowerModeConfig] {
|
func getAllAvailableConfigurations() -> [PowerModeConfig] {
|
||||||
return [defaultConfig] + configurations
|
return [defaultConfig] + configurations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isEmojiInUse(_ emoji: String) -> Bool {
|
||||||
|
if defaultConfig.emoji == emoji {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return configurations.contains { $0.emoji == emoji }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -157,6 +157,12 @@ struct ConfigurationView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(mode.isEditingDefault)
|
.disabled(mode.isEditingDefault)
|
||||||
.opacity(mode.isEditingDefault ? 0.5 : 1)
|
.opacity(mode.isEditingDefault ? 0.5 : 1)
|
||||||
|
.popover(isPresented: $isShowingEmojiPicker, arrowEdge: .bottom) {
|
||||||
|
EmojiPickerView(
|
||||||
|
selectedEmoji: $selectedEmoji,
|
||||||
|
isPresented: $isShowingEmojiPicker
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
TextField("Name your power mode", text: $configName)
|
TextField("Name your power mode", text: $configName)
|
||||||
.font(.system(size: 18, weight: .bold))
|
.font(.system(size: 18, weight: .bold))
|
||||||
@ -176,31 +182,14 @@ struct ConfigurationView: View {
|
|||||||
.background(CardBackground(isSelected: false))
|
.background(CardBackground(isSelected: false))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Emoji Picker Overlay
|
// Enhanced Emoji Picker with Custom Emoji Support
|
||||||
if isShowingEmojiPicker {
|
// if isShowingEmojiPicker { // <<< This conditional block will be removed
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))], spacing: 12) {
|
// EmojiPickerView(
|
||||||
ForEach(commonEmojis, id: \.self) { emoji in
|
// selectedEmoji: $selectedEmoji,
|
||||||
Button(action: {
|
// isPresented: $isShowingEmojiPicker
|
||||||
selectedEmoji = emoji
|
// )
|
||||||
isShowingEmojiPicker = false
|
// .padding(.horizontal)
|
||||||
}) {
|
// }
|
||||||
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
|
// SECTION 1: TRIGGERS
|
||||||
if !mode.isEditingDefault {
|
if !mode.isEditingDefault {
|
||||||
|
|||||||
@ -70,10 +70,10 @@ class Recorder: ObservableObject {
|
|||||||
if deviceID != 0 {
|
if deviceID != 0 {
|
||||||
do {
|
do {
|
||||||
try await configureAudioSession(with: deviceID)
|
try await configureAudioSession(with: deviceID)
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
} catch {
|
} catch {
|
||||||
logger.warning("⚠️ Failed to configure audio session for device \(deviceID), attempting to continue: \(error.localizedDescription)")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user