BusinessCard/BusinessCard/Models/ContactFieldInputRules.swift

225 lines
8.6 KiB
Swift

import Foundation
enum ContactFieldInputRules {
private static let phoneFieldIDs: Set<String> = ["phone", "whatsapp", "signal"]
private static let strictWebLinkFieldIDs: Set<String> = ["website", "customLink", "calendly"]
private static let usernameOrLinkFieldIDs: Set<String> = [
"linkedIn", "twitter", "instagram", "facebook", "tiktok", "threads",
"youtube", "snapchat", "pinterest", "twitch", "bluesky", "mastodon", "reddit",
"github", "gitlab", "stackoverflow",
"telegram", "discord", "slack", "matrix",
"patreon", "kofi"
]
static func shouldFormatAsPhone(_ fieldTypeID: String) -> Bool {
phoneFieldIDs.contains(fieldTypeID)
}
nonisolated static func linkedInProfileURL(for value: String) -> URL? {
guard let normalized = normalizedLinkedInProfileURLString(value) else { return nil }
return URL(string: normalized)
}
static func isValid(_ value: String, for fieldTypeID: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if phoneFieldIDs.contains(fieldTypeID) {
return PhoneNumberText.isValid(trimmed)
}
switch fieldTypeID {
case "linkedIn":
return linkedInProfileURL(for: trimmed) != nil
case "email":
return EmailText.isValid(trimmed)
case "zelle":
return PhoneNumberText.isValid(trimmed) || EmailText.isValid(trimmed)
case "venmo":
return isValidVenmo(trimmed)
case "cashApp":
return isValidCashApp(trimmed)
case "paypal":
return isValidPayPal(trimmed)
default:
break
}
if strictWebLinkFieldIDs.contains(fieldTypeID) {
return WebLinkText.isValid(trimmed)
}
if usernameOrLinkFieldIDs.contains(fieldTypeID) {
return isValidUsernameOrLink(trimmed)
}
return !trimmed.isEmpty
}
static func normalizedForStorage(_ value: String, for fieldTypeID: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
if phoneFieldIDs.contains(fieldTypeID) {
return PhoneNumberText.normalizedForStorage(trimmed)
}
switch fieldTypeID {
case "linkedIn":
return linkedInProfileURL(for: trimmed)?.absoluteString ?? trimmed
case "email":
return EmailText.normalizedForStorage(trimmed)
case "zelle":
if EmailText.isValid(trimmed) {
return EmailText.normalizedForStorage(trimmed)
}
if PhoneNumberText.isValid(trimmed) {
return PhoneNumberText.normalizedForStorage(trimmed)
}
return trimmed
case "paypal":
if EmailText.isValid(trimmed) {
return EmailText.normalizedForStorage(trimmed)
}
if WebLinkText.isValid(trimmed) {
return WebLinkText.normalizedForStorage(trimmed)
}
return trimmed
default:
break
}
if strictWebLinkFieldIDs.contains(fieldTypeID) || usernameOrLinkFieldIDs.contains(fieldTypeID) {
if WebLinkText.isValid(trimmed) {
return WebLinkText.normalizedForStorage(trimmed)
}
}
return trimmed
}
static func validationMessage(for fieldTypeID: String) -> String {
if phoneFieldIDs.contains(fieldTypeID) {
return String(localized: "Enter a valid phone number")
}
switch fieldTypeID {
case "linkedIn":
return String(localized: "Enter a valid LinkedIn profile username or link")
case "email":
return String(localized: "Enter a valid email address")
case "zelle":
return String(localized: "Enter a valid phone number or email")
case "venmo":
return String(localized: "Enter a valid Venmo username")
case "cashApp":
return String(localized: "Enter a valid Cash App cashtag")
case "paypal":
return String(localized: "Enter a valid PayPal username, email, or link")
default:
break
}
if strictWebLinkFieldIDs.contains(fieldTypeID) {
return String(localized: "Enter a valid web link")
}
if usernameOrLinkFieldIDs.contains(fieldTypeID) {
return String(localized: "Enter a valid username or link")
}
return String(localized: "Enter a value")
}
}
// MARK: - Private Validation Helpers
private extension ContactFieldInputRules {
nonisolated static func normalizedLinkedInProfileURLString(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !trimmed.contains(where: \.isWhitespace) else { return nil }
if let url = URL(string: trimmed),
let host = url.host?.lowercased(),
host.contains("linkedin.com") {
let pathComponents = url.path
.split(separator: "/")
.map(String.init)
guard pathComponents.count >= 2,
pathComponents[0].lowercased() == "in",
let handle = normalizedLinkedInHandle(pathComponents[1]) else {
return nil
}
return "https://www.linkedin.com/in/\(handle)/"
}
var candidate = trimmed
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: "")
.replacingOccurrences(of: "www.", with: "")
if candidate.lowercased().hasPrefix("linkedin.com/") {
candidate = String(candidate.dropFirst("linkedin.com/".count))
guard candidate.lowercased().hasPrefix("in/") else { return nil }
candidate = String(candidate.dropFirst("in/".count))
} else if candidate.lowercased().hasPrefix("in/") {
candidate = String(candidate.dropFirst("in/".count))
}
let handleCandidate = candidate
.split(separator: "/")
.first
.map(String.init) ?? candidate
guard let handle = normalizedLinkedInHandle(handleCandidate) else { return nil }
return "https://www.linkedin.com/in/\(handle)/"
}
nonisolated static func normalizedLinkedInHandle(_ value: String) -> String? {
let handle = value.hasPrefix("@") ? String(value.dropFirst()) : value
guard !handle.isEmpty else { return nil }
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
guard handle.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
return handle
}
static func isValidUsernameOrLink(_ value: String) -> Bool {
guard !value.contains(where: \.isWhitespace) else { return false }
if WebLinkText.isValid(value) { return true }
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "@._-/:+$#"))
let hasValidScalars = value.unicodeScalars.allSatisfy { allowed.contains($0) }
let hasContent = value.contains(where: { $0.isLetter || $0.isNumber })
return hasValidScalars && hasContent && value.count >= 2
}
static func isValidVenmo(_ value: String) -> Bool {
guard !value.contains(where: \.isWhitespace) else { return false }
let username = value.hasPrefix("@") ? String(value.dropFirst()) : value
return isSimpleHandle(username, min: 2, max: 30, allowDash: true)
}
static func isValidCashApp(_ value: String) -> Bool {
guard !value.contains(where: \.isWhitespace) else { return false }
let cashtag = value.hasPrefix("$") ? String(value.dropFirst()) : value
return isSimpleHandle(cashtag, min: 1, max: 20, allowDash: false)
}
static func isValidPayPal(_ value: String) -> Bool {
guard !value.contains(where: \.isWhitespace) else { return false }
if EmailText.isValid(value) || WebLinkText.isValid(value) {
return true
}
return isSimpleHandle(value, min: 2, max: 64, allowDash: true)
}
static func isSimpleHandle(_ value: String, min: Int, max: Int, allowDash: Bool) -> Bool {
guard !value.isEmpty, (min...max).contains(value.count) else { return false }
let extraCharacters = allowDash ? "._-" : "_"
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: extraCharacters))
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
}
}