228 lines
8.6 KiB
Swift
228 lines
8.6 KiB
Swift
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 picker.")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
.padding(.top, 2)
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.bottom, 5)
|
||
}
|
||
}
|
||
.padding()
|
||
.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)
|
||
.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)
|
||
.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
|
||
|
||
|