vOOice/VoiceInk/Models/CustomPrompt.swift

392 lines
15 KiB
Swift

import Foundation
import SwiftUI
typealias PromptIcon = String
extension PromptIcon {
static let allCases: [PromptIcon] = [
// Document & Text
"doc.text.fill",
"textbox",
"checkmark.seal.fill",
// Communication
"bubble.left.and.bubble.right.fill",
"message.fill",
"envelope.fill",
// Professional
"person.2.fill",
"person.wave.2.fill",
"briefcase.fill",
// Technical
"curlybraces",
"terminal.fill",
"gearshape.fill",
// Content
"doc.text.image.fill",
"note",
"book.fill",
"bookmark.fill",
"pencil.circle.fill",
// Media & Creative
"video.fill",
"mic.fill",
"music.note",
"photo.fill",
"paintbrush.fill",
// Productivity & Time
"clock.fill",
"calendar",
"list.bullet",
"checkmark.circle.fill",
"timer",
"hourglass",
"star.fill",
"flag.fill",
"tag.fill",
"folder.fill",
"paperclip",
"tray.fill",
"chart.bar.fill",
"flame.fill",
"target",
"list.clipboard.fill",
"brain.head.profile",
"lightbulb.fill",
"megaphone.fill",
"heart.fill",
"map.fill",
"house.fill",
"camera.fill",
"figure.walk",
"dumbbell.fill",
"cart.fill",
"creditcard.fill",
"graduationcap.fill",
"airplane",
"leaf.fill",
"hand.raised.fill",
"hand.thumbsup.fill"
]
}
struct CustomPrompt: Identifiable, Codable, Equatable {
let id: UUID
let title: String
let promptText: String
var isActive: Bool
let icon: PromptIcon
let description: String?
let isPredefined: Bool
let triggerWords: [String]
let useSystemInstructions: Bool
init(
id: UUID = UUID(),
title: String,
promptText: String,
isActive: Bool = false,
icon: PromptIcon = "doc.text.fill",
description: String? = nil,
isPredefined: Bool = false,
triggerWords: [String] = [],
useSystemInstructions: Bool = true
) {
self.id = id
self.title = title
self.promptText = promptText
self.isActive = isActive
self.icon = icon
self.description = description
self.isPredefined = isPredefined
self.triggerWords = triggerWords
self.useSystemInstructions = useSystemInstructions
}
enum CodingKeys: String, CodingKey {
case id, title, promptText, isActive, icon, description, isPredefined, triggerWords, useSystemInstructions
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
promptText = try container.decode(String.self, forKey: .promptText)
isActive = try container.decode(Bool.self, forKey: .isActive)
icon = try container.decode(PromptIcon.self, forKey: .icon)
description = try container.decodeIfPresent(String.self, forKey: .description)
isPredefined = try container.decode(Bool.self, forKey: .isPredefined)
triggerWords = try container.decode([String].self, forKey: .triggerWords)
useSystemInstructions = try container.decodeIfPresent(Bool.self, forKey: .useSystemInstructions) ?? true
}
var finalPromptText: String {
if useSystemInstructions {
return String(format: AIPrompts.customPromptTemplate, self.promptText)
} else {
return self.promptText
}
}
}
// MARK: - UI Extensions
extension CustomPrompt {
func promptIcon(isSelected: Bool, onTap: @escaping () -> Void, onEdit: ((CustomPrompt) -> Void)? = nil, onDelete: ((CustomPrompt) -> Void)? = nil) -> some View {
VStack(spacing: 8) {
ZStack {
// Dynamic background with blur effect
RoundedRectangle(cornerRadius: 14)
.fill(
LinearGradient(
gradient: isSelected ?
Gradient(colors: [
Color.accentColor.opacity(0.9),
Color.accentColor.opacity(0.7)
]) :
Gradient(colors: [
Color(NSColor.controlBackgroundColor).opacity(0.95),
Color(NSColor.controlBackgroundColor).opacity(0.85)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(
LinearGradient(
gradient: Gradient(colors: [
isSelected ?
Color.white.opacity(0.3) : Color.white.opacity(0.15),
isSelected ?
Color.white.opacity(0.1) : Color.white.opacity(0.05)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
.shadow(
color: isSelected ?
Color.accentColor.opacity(0.4) : Color.black.opacity(0.1),
radius: isSelected ? 10 : 6,
x: 0,
y: 3
)
// Decorative background elements
Circle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
isSelected ?
Color.white.opacity(0.15) : Color.white.opacity(0.08),
Color.clear
]),
center: .center,
startRadius: 1,
endRadius: 25
)
)
.frame(width: 50, height: 50)
.offset(x: -15, y: -15)
.blur(radius: 2)
// Icon with enhanced effects
Image(systemName: icon)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(
LinearGradient(
colors: isSelected ?
[Color.white, Color.white.opacity(0.9)] :
[Color.primary.opacity(0.9), Color.primary.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.shadow(
color: isSelected ?
Color.white.opacity(0.5) : Color.clear,
radius: 4
)
.shadow(
color: isSelected ?
Color.accentColor.opacity(0.5) : Color.clear,
radius: 3
)
}
.frame(width: 48, height: 48)
// Enhanced title styling
VStack(spacing: 2) {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundColor(isSelected ?
.primary : .secondary)
.lineLimit(1)
.frame(maxWidth: 70)
// Trigger word section with consistent height
ZStack(alignment: .center) {
if !triggerWords.isEmpty {
HStack(spacing: 2) {
Image(systemName: "mic.fill")
.font(.system(size: 7))
.foregroundColor(isSelected ? .accentColor.opacity(0.9) : .secondary.opacity(0.7))
if triggerWords.count == 1 {
Text("\"\(triggerWords[0])...\"")
.font(.system(size: 8, weight: .regular))
.foregroundColor(isSelected ? .primary.opacity(0.8) : .secondary.opacity(0.7))
.lineLimit(1)
} else {
Text("\"\(triggerWords[0])...\" +\(triggerWords.count - 1)")
.font(.system(size: 8, weight: .regular))
.foregroundColor(isSelected ? .primary.opacity(0.8) : .secondary.opacity(0.7))
.lineLimit(1)
}
}
.frame(maxWidth: 70)
}
}
.frame(height: 16)
}
}
.padding(.horizontal, 4)
.padding(.vertical, 6)
.contentShape(Rectangle())
.scaleEffect(isSelected ? 1.05 : 1.0)
.onTapGesture(count: 2) {
// Double tap to edit
if let onEdit = onEdit {
onEdit(self)
}
}
.onTapGesture(count: 1) {
// Single tap to select
onTap()
}
.contextMenu {
if onEdit != nil || onDelete != nil {
if let onEdit = onEdit {
Button {
onEdit(self)
} label: {
Label("Edit", systemImage: "pencil")
}
}
if let onDelete = onDelete, !isPredefined {
Button(role: .destructive) {
let alert = NSAlert()
alert.messageText = "Delete Prompt?"
alert.informativeText = "Are you sure you want to delete '\(self.title)' prompt? This action cannot be undone."
alert.alertStyle = .warning
alert.addButton(withTitle: "Delete")
alert.addButton(withTitle: "Cancel")
let response = alert.runModal()
if response == .alertFirstButtonReturn {
onDelete(self)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
// Static method to create an "Add New" button with the same styling as the prompt icons
static func addNewButton(action: @escaping () -> Void) -> some View {
VStack(spacing: 8) {
ZStack {
// Dynamic background with blur effect - same styling as promptIcon
RoundedRectangle(cornerRadius: 14)
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color(NSColor.controlBackgroundColor).opacity(0.95),
Color(NSColor.controlBackgroundColor).opacity(0.85)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(
LinearGradient(
gradient: Gradient(colors: [
Color.white.opacity(0.15),
Color.white.opacity(0.05)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
.shadow(
color: Color.black.opacity(0.1),
radius: 6,
x: 0,
y: 3
)
// Decorative background elements (same as in promptIcon)
Circle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
Color.white.opacity(0.08),
Color.clear
]),
center: .center,
startRadius: 1,
endRadius: 25
)
)
.frame(width: 50, height: 50)
.offset(x: -15, y: -15)
.blur(radius: 2)
// Plus icon with same styling as the normal icons
Image(systemName: "plus.circle.fill")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(
LinearGradient(
colors: [Color.accentColor.opacity(0.9), Color.accentColor.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
.frame(width: 48, height: 48)
// Text label with matching styling
VStack(spacing: 2) {
Text("Add New")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
.lineLimit(1)
.frame(maxWidth: 70)
// Empty space matching the trigger word area height
Spacer()
.frame(height: 16)
}
}
.padding(.horizontal, 4)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture(perform: action)
}
}