BusinessCard/BusinessCard/Models/ContactFieldType.swift

709 lines
25 KiB
Swift

import SwiftUI
/// Category for grouping contact field types in the picker
enum ContactFieldCategory: String, CaseIterable, Sendable {
case contact
case social
case developer
case messaging
case payment
case creator
case scheduling
case other
var displayName: String {
switch self {
case .contact: return String(localized: "Contact")
case .social: return String(localized: "Social Media")
case .developer: return String(localized: "Developer")
case .messaging: return String(localized: "Messaging")
case .payment: return String(localized: "Payment")
case .creator: return String(localized: "Support & Funding")
case .scheduling: return String(localized: "Scheduling")
case .other: return String(localized: "Other")
}
}
}
/// Defines a contact field type with all its configuration
struct ContactFieldType: Identifiable, Hashable, Sendable {
let id: String
let displayName: String
let systemImage: String
let isCustomSymbol: Bool // true = asset catalog symbol, false = SF Symbol
let iconColor: Color
let category: ContactFieldCategory
let valueLabel: String
let valuePlaceholder: String
let titleSuggestions: [String]
let keyboardType: UIKeyboardType
let autocapitalization: TextInputAutocapitalization
let urlBuilder: @Sendable (String) -> URL?
let displayValueFormatter: @Sendable (String) -> String
init(
id: String,
displayName: String,
systemImage: String,
isCustomSymbol: Bool = false,
iconColor: Color,
category: ContactFieldCategory,
valueLabel: String,
valuePlaceholder: String,
titleSuggestions: [String],
keyboardType: UIKeyboardType,
autocapitalization: TextInputAutocapitalization = .never,
urlBuilder: @escaping @Sendable (String) -> URL?,
displayValueFormatter: @escaping @Sendable (String) -> String = { $0 }
) {
self.id = id
self.displayName = displayName
self.systemImage = systemImage
self.isCustomSymbol = isCustomSymbol
self.iconColor = iconColor
self.category = category
self.valueLabel = valueLabel
self.valuePlaceholder = valuePlaceholder
self.titleSuggestions = titleSuggestions
self.keyboardType = keyboardType
self.autocapitalization = autocapitalization
self.urlBuilder = urlBuilder
self.displayValueFormatter = displayValueFormatter
}
/// Returns an Image view for this field type's icon
@MainActor
func iconImage() -> Image {
if isCustomSymbol {
return Image(systemImage)
} else {
return Image(systemName: systemImage)
}
}
// MARK: - Hashable & Equatable
static func == (lhs: ContactFieldType, rhs: ContactFieldType) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
// MARK: - URL Building
func buildURL(value: String) -> URL? {
urlBuilder(value)
}
// MARK: - Display Value Formatting
/// Returns a formatted display value for the given raw value
func formattedDisplayValue(_ value: String) -> String {
displayValueFormatter(value)
}
}
// MARK: - All Field Types
extension ContactFieldType {
/// All available field types
static let allCases: [ContactFieldType] = [
// Contact
.phone, .email, .website, .address,
// Social
.linkedIn, .twitter, .instagram, .facebook, .tiktok, .threads,
.youtube, .snapchat, .pinterest, .twitch, .bluesky, .mastodon, .reddit,
// Developer
.github, .gitlab, .stackoverflow,
// Messaging
.telegram, .whatsapp, .signal, .discord, .slack, .matrix,
// Payment
.venmo, .cashApp, .paypal, .zelle,
// Creator
.patreon, .kofi,
// Scheduling
.calendly,
// Other
.customLink
]
/// Field types grouped by category
static var byCategory: [ContactFieldCategory: [ContactFieldType]] {
Dictionary(grouping: allCases, by: { $0.category })
}
// MARK: - Contact
static let phone = ContactFieldType(
id: "phone",
displayName: String(localized: "Phone Number"),
systemImage: "phone.fill",
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
category: .contact,
valueLabel: String(localized: "Phone Number"),
valuePlaceholder: "+1 (555) 123-4567",
titleSuggestions: [String(localized: "Cell"), String(localized: "Work"), String(localized: "Home")],
keyboardType: .phonePad,
urlBuilder: { value in
let digits = value.filter { $0.isNumber || $0 == "+" }
return URL(string: "tel:\(digits)")
}
)
static let email = ContactFieldType(
id: "email",
displayName: String(localized: "Email"),
systemImage: "envelope.fill",
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
category: .contact,
valueLabel: String(localized: "Email"),
valuePlaceholder: "you@example.com",
titleSuggestions: [String(localized: "Work"), String(localized: "Personal")],
keyboardType: .emailAddress,
urlBuilder: { URL(string: "mailto:\($0)") }
)
static let website = ContactFieldType(
id: "website",
displayName: String(localized: "Company Website"),
systemImage: "globe",
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
category: .contact,
valueLabel: String(localized: "Website URL"),
valuePlaceholder: "https://company.com",
titleSuggestions: [String(localized: "Company Website"), String(localized: "Portfolio")],
keyboardType: .URL,
urlBuilder: { buildWebURL($0) }
)
static let address = ContactFieldType(
id: "address",
displayName: String(localized: "Address"),
systemImage: "location.fill",
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
category: .contact,
valueLabel: String(localized: "Address"),
valuePlaceholder: "123 Main St, City, State",
titleSuggestions: [String(localized: "Work"), String(localized: "Home")],
keyboardType: .default,
urlBuilder: { value in
// Try to parse as PostalAddress JSON first for proper formatting
let searchQuery: String
if let address = PostalAddress.decode(from: value) {
searchQuery = address.singleLineString
} else {
searchQuery = value
}
let encoded = searchQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? searchQuery
return URL(string: "maps://?q=\(encoded)")
},
displayValueFormatter: formatAddressForDisplay
)
// MARK: - Social Media
static let linkedIn = ContactFieldType(
id: "linkedIn",
displayName: "LinkedIn",
systemImage: "linkedin",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "linkedin.com/in/username",
titleSuggestions: ["Connect with me on LinkedIn"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "linkedin.com") }
)
static let twitter = ContactFieldType(
id: "twitter",
displayName: "X",
systemImage: "x-twitter",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "x.com/username",
titleSuggestions: ["Follow me on X"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "x.com") }
)
static let instagram = ContactFieldType(
id: "instagram",
displayName: "Instagram",
systemImage: "instagram",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "instagram.com/username",
titleSuggestions: ["Follow me on Instagram"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "instagram.com") }
)
static let facebook = ContactFieldType(
id: "facebook",
displayName: "Facebook",
systemImage: "facebook",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "facebook.com/username",
titleSuggestions: ["Connect on Facebook"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "facebook.com") }
)
static let tiktok = ContactFieldType(
id: "tiktok",
displayName: "TikTok",
systemImage: "tiktok",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "tiktok.com/@username",
titleSuggestions: ["Follow me on TikTok"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "tiktok.com") }
)
static let threads = ContactFieldType(
id: "threads",
displayName: "Threads",
systemImage: "threads",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "threads.net/@username",
titleSuggestions: ["Follow me on Threads"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "threads.net") }
)
static let youtube = ContactFieldType(
id: "youtube",
displayName: "YouTube",
systemImage: "youtube",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "youtube.com/@channel",
titleSuggestions: ["Subscribe to my channel"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "youtube.com") }
)
static let snapchat = ContactFieldType(
id: "snapchat",
displayName: "Snapchat",
systemImage: "camera.fill",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "snapchat.com/add/username",
titleSuggestions: ["Add me on Snapchat"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "snapchat.com") }
)
static let pinterest = ContactFieldType(
id: "pinterest",
displayName: "Pinterest",
systemImage: "pin.fill",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "pinterest.com/username",
titleSuggestions: ["Follow me on Pinterest"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "pinterest.com") }
)
static let twitch = ContactFieldType(
id: "twitch",
displayName: "Twitch",
systemImage: "twitch",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "twitch.tv/username",
titleSuggestions: ["Watch me on Twitch"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "twitch.tv") }
)
static let bluesky = ContactFieldType(
id: "bluesky",
displayName: "Bluesky",
systemImage: "bluesky",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "bsky.app/profile/username",
titleSuggestions: ["Follow me on Bluesky"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "bsky.app") }
)
static let mastodon = ContactFieldType(
id: "mastodon",
displayName: "Mastodon",
systemImage: "mastodon",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "mastodon.social/@username",
titleSuggestions: ["Follow me on Mastodon"],
keyboardType: .URL,
urlBuilder: { value in
if value.hasPrefix("http://") || value.hasPrefix("https://") {
return URL(string: value)
}
if value.contains(".") {
return URL(string: "https://\(value)")
}
let username = value.hasPrefix("@") ? String(value.dropFirst()) : value
return URL(string: "https://mastodon.social/@\(username)")
}
)
static let reddit = ContactFieldType(
id: "reddit",
displayName: "Reddit",
systemImage: "reddit",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "reddit.com/user/username",
titleSuggestions: ["Follow me on Reddit"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "reddit.com") }
)
// MARK: - Developer
static let github = ContactFieldType(
id: "github",
displayName: "GitHub",
systemImage: "github.fill",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .developer,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "github.com/username",
titleSuggestions: ["View our work on GitHub", "View our GitHub Repo"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "github.com") }
)
static let gitlab = ContactFieldType(
id: "gitlab",
displayName: "GitLab",
systemImage: "chevron.left.forwardslash.chevron.right",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .developer,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "gitlab.com/username",
titleSuggestions: ["View our work on GitLab"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "gitlab.com") }
)
static let stackoverflow = ContactFieldType(
id: "stackoverflow",
displayName: "Stack Overflow",
systemImage: "text.bubble.fill",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .developer,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "stackoverflow.com/users/id",
titleSuggestions: ["Ask me on Stack Overflow"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "stackoverflow.com") }
)
// MARK: - Messaging
static let telegram = ContactFieldType(
id: "telegram",
displayName: "Telegram",
systemImage: "telegram",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "t.me/username",
titleSuggestions: ["Connect with me on Telegram"],
keyboardType: .URL,
urlBuilder: { value in
if value.hasPrefix("t.me/") || value.hasPrefix("https://t.me/") {
return URL(string: value.hasPrefix("https://") ? value : "https://\(value)")
}
return URL(string: "tg://resolve?domain=\(value)")
}
)
static let whatsapp = ContactFieldType(
id: "whatsapp",
displayName: "WhatsApp",
systemImage: "message.fill",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "+1 555 123 4567",
titleSuggestions: ["Message me on WhatsApp"],
keyboardType: .phonePad,
urlBuilder: { value in
let digits = value.filter { $0.isNumber }
return URL(string: "https://wa.me/\(digits)")
}
)
static let signal = ContactFieldType(
id: "signal",
displayName: "Signal",
systemImage: "bubble.left.fill",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "+1 555 123 4567",
titleSuggestions: ["Message me on Signal"],
keyboardType: .phonePad,
urlBuilder: { value in
let digits = value.filter { $0.isNumber || $0 == "+" }
return URL(string: "sgnl://signal.me/#p/\(digits)")
}
)
static let discord = ContactFieldType(
id: "discord",
displayName: "Discord",
systemImage: "discord",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "discord.gg/invite",
titleSuggestions: ["Join my Discord"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "discord.gg") }
)
static let slack = ContactFieldType(
id: "slack",
displayName: "Slack",
systemImage: "slack",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "yourworkspace.slack.com",
titleSuggestions: ["Join our Slack"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "slack.com") }
)
static let matrix = ContactFieldType(
id: "matrix",
displayName: "Matrix",
systemImage: "matrix",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
valuePlaceholder: "@username:matrix.org",
titleSuggestions: ["Chat with me on Matrix"],
keyboardType: .URL,
urlBuilder: { value in
if value.contains("matrix.to") || value.contains("element.io") {
return URL(string: value.hasPrefix("https://") ? value : "https://\(value)")
}
let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
return URL(string: "https://matrix.to/#/\(encoded)")
}
)
// MARK: - Payment
static let venmo = ContactFieldType(
id: "venmo",
displayName: "Venmo",
systemImage: "dollarsign.circle.fill",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .payment,
valueLabel: String(localized: "Username"),
valuePlaceholder: "@username",
titleSuggestions: ["Pay via Venmo"],
keyboardType: .default,
urlBuilder: { value in
let username = value.hasPrefix("@") ? String(value.dropFirst()) : value
return URL(string: "venmo://users/\(username)")
}
)
static let cashApp = ContactFieldType(
id: "cashApp",
displayName: "Cash App",
systemImage: "dollarsign.square.fill",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .payment,
valueLabel: String(localized: "Username"),
valuePlaceholder: "$cashtag",
titleSuggestions: ["Pay via Cash App"],
keyboardType: .default,
urlBuilder: { value in
let cashtag = value.hasPrefix("$") ? String(value.dropFirst()) : value
return URL(string: "cashapp://cash.app/$\(cashtag)")
}
)
static let paypal = ContactFieldType(
id: "paypal",
displayName: "PayPal",
systemImage: "creditcard.fill",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .payment,
valueLabel: String(localized: "Email or Username"),
valuePlaceholder: "paypal.me/username",
titleSuggestions: ["Pay via PayPal"],
keyboardType: .emailAddress,
urlBuilder: { URL(string: "https://paypal.me/\($0)") }
)
static let zelle = ContactFieldType(
id: "zelle",
displayName: "Zelle",
systemImage: "dollarsign.arrow.circlepath",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .payment,
valueLabel: String(localized: "Phone or Email"),
valuePlaceholder: "email@example.com",
titleSuggestions: ["Pay via Zelle"],
keyboardType: .phonePad,
urlBuilder: { _ in nil } // Zelle has no universal deep link
)
// MARK: - Creator/Funding
static let patreon = ContactFieldType(
id: "patreon",
displayName: "Patreon",
systemImage: "patreon.fill",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .creator,
valueLabel: String(localized: "Profile Link"),
valuePlaceholder: "patreon.com/username",
titleSuggestions: ["Support me on Patreon"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "patreon.com") }
)
static let kofi = ContactFieldType(
id: "kofi",
displayName: "Ko-fi",
systemImage: "ko-fi",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .creator,
valueLabel: String(localized: "Profile Link"),
valuePlaceholder: "ko-fi.com/username",
titleSuggestions: ["Buy me a coffee"],
keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "ko-fi.com") }
)
// MARK: - Scheduling
static let calendly = ContactFieldType(
id: "calendly",
displayName: "Calendly",
systemImage: "calendar",
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .scheduling,
valueLabel: String(localized: "Calendly Link"),
valuePlaceholder: "calendly.com/username",
titleSuggestions: ["Schedule a meeting"],
keyboardType: .URL,
urlBuilder: { buildWebURL($0) }
)
// MARK: - Other
static let customLink = ContactFieldType(
id: "customLink",
displayName: String(localized: "Link"),
systemImage: "link",
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
category: .other,
valueLabel: String(localized: "URL"),
valuePlaceholder: "https://example.com",
titleSuggestions: [],
keyboardType: .URL,
urlBuilder: { buildWebURL($0) }
)
}
// MARK: - Display Value Formatters
/// Formats an address for multi-line display
/// Tries to parse as structured PostalAddress JSON first, falls back to legacy format
nonisolated private func formatAddressForDisplay(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return value }
// Parse as structured PostalAddress
if let address = PostalAddress.decode(from: trimmed), address.hasValue {
return address.formattedString
}
// Not a valid address format
return trimmed
}
// MARK: - URL Helper Functions
nonisolated private func buildWebURL(_ value: String) -> URL? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
return URL(string: trimmed)
}
return URL(string: "https://\(trimmed)")
}
nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
// If already a full URL, use it
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
return URL(string: trimmed)
}
// If contains the base domain, add https
if trimmed.contains(webBase) {
return URL(string: "https://\(trimmed)")
}
// Otherwise, treat as username and build URL
let username = trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
return URL(string: "https://\(webBase)/\(username)")
}