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 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 }
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user