Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
6423bc3b76
commit
e6f6684c88
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@ Package.resolved
|
||||
*.xcworkspace/xcuserdata/
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
build/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
224
BusinessCard/Models/ContactFieldInputRules.swift
Normal file
224
BusinessCard/Models/ContactFieldInputRules.swift
Normal file
@ -0,0 +1,224 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@ -193,7 +193,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "linkedin.com/in/username",
|
||||
titleSuggestions: ["Connect with me on LinkedIn"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "linkedin.com") }
|
||||
urlBuilder: { ContactFieldInputRules.linkedInProfileURL(for: $0) }
|
||||
)
|
||||
|
||||
static let twitter = ContactFieldType(
|
||||
@ -207,7 +207,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "x.com/username",
|
||||
titleSuggestions: ["Follow me on X"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "x.com") }
|
||||
urlBuilder: { buildXProfileURL($0) }
|
||||
)
|
||||
|
||||
static let instagram = ContactFieldType(
|
||||
@ -221,7 +221,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "instagram.com/username",
|
||||
titleSuggestions: ["Follow me on Instagram"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "instagram.com") }
|
||||
urlBuilder: { buildInstagramProfileURL($0) }
|
||||
)
|
||||
|
||||
static let facebook = ContactFieldType(
|
||||
@ -249,7 +249,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "tiktok.com/@username",
|
||||
titleSuggestions: ["Follow me on TikTok"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "tiktok.com") }
|
||||
urlBuilder: { buildTikTokProfileURL($0) }
|
||||
)
|
||||
|
||||
static let threads = ContactFieldType(
|
||||
@ -263,7 +263,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "threads.net/@username",
|
||||
titleSuggestions: ["Follow me on Threads"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "threads.net") }
|
||||
urlBuilder: { buildThreadsProfileURL($0) }
|
||||
)
|
||||
|
||||
static let youtube = ContactFieldType(
|
||||
@ -277,7 +277,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "youtube.com/@channel",
|
||||
titleSuggestions: ["Subscribe to my channel"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "youtube.com") }
|
||||
urlBuilder: { buildYouTubeProfileURL($0) }
|
||||
)
|
||||
|
||||
static let snapchat = ContactFieldType(
|
||||
@ -290,7 +290,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "snapchat.com/add/username",
|
||||
titleSuggestions: ["Add me on Snapchat"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "snapchat.com") }
|
||||
urlBuilder: { buildSnapchatProfileURL($0) }
|
||||
)
|
||||
|
||||
static let pinterest = ContactFieldType(
|
||||
@ -331,7 +331,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "bsky.app/profile/username",
|
||||
titleSuggestions: ["Follow me on Bluesky"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "bsky.app") }
|
||||
urlBuilder: { buildBlueskyProfileURL($0) }
|
||||
)
|
||||
|
||||
static let mastodon = ContactFieldType(
|
||||
@ -368,7 +368,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "reddit.com/user/username",
|
||||
titleSuggestions: ["Follow me on Reddit"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "reddit.com") }
|
||||
urlBuilder: { buildRedditProfileURL($0) }
|
||||
)
|
||||
|
||||
// MARK: - Developer
|
||||
@ -410,7 +410,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "stackoverflow.com/users/id",
|
||||
titleSuggestions: ["Ask me on Stack Overflow"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "stackoverflow.com") }
|
||||
urlBuilder: { buildStackOverflowProfileURL($0) }
|
||||
)
|
||||
|
||||
// MARK: - Messaging
|
||||
@ -426,12 +426,7 @@ extension ContactFieldType {
|
||||
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)")
|
||||
}
|
||||
urlBuilder: { buildTelegramProfileURL($0) }
|
||||
)
|
||||
|
||||
static let whatsapp = ContactFieldType(
|
||||
@ -477,7 +472,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "discord.gg/invite",
|
||||
titleSuggestions: ["Join my Discord"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "discord.gg") }
|
||||
urlBuilder: { buildDiscordInviteURL($0) }
|
||||
)
|
||||
|
||||
static let slack = ContactFieldType(
|
||||
@ -491,7 +486,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "yourworkspace.slack.com",
|
||||
titleSuggestions: ["Join our Slack"],
|
||||
keyboardType: .URL,
|
||||
urlBuilder: { buildSocialURL($0, webBase: "slack.com") }
|
||||
urlBuilder: { buildSlackWorkspaceURL($0) }
|
||||
)
|
||||
|
||||
static let matrix = ContactFieldType(
|
||||
@ -526,10 +521,7 @@ extension ContactFieldType {
|
||||
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)")
|
||||
}
|
||||
urlBuilder: { buildVenmoProfileURL($0) }
|
||||
)
|
||||
|
||||
static let cashApp = ContactFieldType(
|
||||
@ -542,10 +534,7 @@ extension ContactFieldType {
|
||||
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)")
|
||||
}
|
||||
urlBuilder: { buildCashAppProfileURL($0) }
|
||||
)
|
||||
|
||||
static let paypal = ContactFieldType(
|
||||
@ -558,7 +547,7 @@ extension ContactFieldType {
|
||||
valuePlaceholder: "paypal.me/username",
|
||||
titleSuggestions: ["Pay via PayPal"],
|
||||
keyboardType: .emailAddress,
|
||||
urlBuilder: { URL(string: "https://paypal.me/\($0)") }
|
||||
urlBuilder: { buildPayPalURL($0) }
|
||||
)
|
||||
|
||||
static let zelle = ContactFieldType(
|
||||
@ -689,4 +678,183 @@ nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL
|
||||
return URL(string: "https://\(webBase)/\(username)")
|
||||
}
|
||||
|
||||
nonisolated private func buildXProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("x.com") || trimmed.contains("twitter.com") {
|
||||
let replaced = trimmed.replacingOccurrences(of: "twitter.com", with: "x.com")
|
||||
return buildWebURL(replaced)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://x.com/\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func buildInstagramProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("instagram.com") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://www.instagram.com/\(handle)/")
|
||||
}
|
||||
|
||||
nonisolated private func buildTikTokProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("tiktok.com") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://www.tiktok.com/@\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func buildThreadsProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("threads.net") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://www.threads.net/@\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func buildYouTubeProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("youtube.com") || trimmed.contains("youtu.be") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://www.youtube.com/@\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func buildSnapchatProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("snapchat.com") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://www.snapchat.com/add/\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func buildBlueskyProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("bsky.app") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
let didLike = handle.contains(".") ? handle : "\(handle).bsky.social"
|
||||
return URL(string: "https://bsky.app/profile/\(didLike)")
|
||||
}
|
||||
|
||||
nonisolated private func buildRedditProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("reddit.com") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
var handle = normalizedHandle(trimmed)
|
||||
if handle.hasPrefix("u/") {
|
||||
handle = String(handle.dropFirst(2))
|
||||
} else if handle.hasPrefix("user/") {
|
||||
handle = String(handle.dropFirst(5))
|
||||
}
|
||||
return URL(string: "https://www.reddit.com/user/\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func buildStackOverflowProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("stackoverflow.com") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handleOrID = normalizedHandle(trimmed)
|
||||
if handleOrID.allSatisfy(\.isNumber) {
|
||||
return URL(string: "https://stackoverflow.com/users/\(handleOrID)")
|
||||
}
|
||||
return URL(string: "https://stackoverflow.com/users/\(handleOrID)")
|
||||
}
|
||||
|
||||
nonisolated private func buildTelegramProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("t.me/") || trimmed.contains("telegram.me/") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://t.me/\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func buildDiscordInviteURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("discord.gg") || trimmed.contains("discord.com/invite/") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let inviteCode = normalizedHandle(trimmed)
|
||||
return URL(string: "https://discord.gg/\(inviteCode)")
|
||||
}
|
||||
|
||||
nonisolated private func buildSlackWorkspaceURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("slack.com") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
if trimmed.contains(".") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
return URL(string: "https://\(trimmed).slack.com")
|
||||
}
|
||||
|
||||
nonisolated private func buildVenmoProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("venmo.com") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://venmo.com/u/\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func buildCashAppProfileURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("cash.app") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
let cashtag = normalizedHandle(trimmed, droppingPrefix: "$")
|
||||
return URL(string: "https://cash.app/$\(cashtag)")
|
||||
}
|
||||
|
||||
nonisolated private func buildPayPalURL(_ value: String) -> URL? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.contains("paypal.me") || trimmed.contains("paypal.com") {
|
||||
return buildWebURL(trimmed)
|
||||
}
|
||||
if EmailText.isValid(trimmed) {
|
||||
return URL(string: "mailto:\(EmailText.normalizedForStorage(trimmed))")
|
||||
}
|
||||
let handle = normalizedHandle(trimmed)
|
||||
return URL(string: "https://paypal.me/\(handle)")
|
||||
}
|
||||
|
||||
nonisolated private func normalizedHandle(_ value: String, droppingPrefix prefix: Character = "@") -> String {
|
||||
var handle = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if handle.hasPrefix("http://") || handle.hasPrefix("https://") {
|
||||
if let url = URL(string: handle),
|
||||
let last = url.path.split(separator: "/").last {
|
||||
handle = String(last)
|
||||
}
|
||||
}
|
||||
if handle.first == prefix {
|
||||
handle.removeFirst()
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
// MARK: - Phone Text Utilities
|
||||
|
||||
@ -205,6 +205,10 @@
|
||||
},
|
||||
"Contact" : {
|
||||
|
||||
},
|
||||
"Contact fields" : {
|
||||
"comment" : "A label displayed above a list of a user's contact fields.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Could not load card: %@" : {
|
||||
"comment" : "A description of an error that might occur when fetching a shared card. The argument is text describing the underlying error.",
|
||||
@ -325,6 +329,46 @@
|
||||
},
|
||||
"Email or Username" : {
|
||||
|
||||
},
|
||||
"Enter a valid Cash App cashtag" : {
|
||||
"comment" : "Validation message for a \"cashApp\" contact field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a valid email address" : {
|
||||
"comment" : "Validation message for an \"email\" contact field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a valid LinkedIn profile username or link" : {
|
||||
"comment" : "Error message when the user inputs a value for a LinkedIn profile username or link field that is not a valid LinkedIn profile username or link.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a valid PayPal username, email, or link" : {
|
||||
"comment" : "Validation message for a \"paypal\" contact field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a valid phone number" : {
|
||||
"comment" : "Validation message for a phone number field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a valid phone number or email" : {
|
||||
"comment" : "Validation message for a \"zelle\" contact field that requires either a valid phone number or email.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a valid username or link" : {
|
||||
"comment" : "Error message displayed when the user inputs a value that is neither a valid username nor a valid link.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a valid Venmo username" : {
|
||||
"comment" : "Validation message for a Venmo username field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a valid web link" : {
|
||||
"comment" : "Validation message for a text field where a valid web link is expected.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enter a value" : {
|
||||
"comment" : "Default validation message for any text field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Expires in 7 days" : {
|
||||
"comment" : "A notice displayed below an App Clip URL that indicates when it will expire.",
|
||||
@ -905,6 +949,10 @@
|
||||
},
|
||||
"Username/Link" : {
|
||||
|
||||
},
|
||||
"View" : {
|
||||
"comment" : "A label for a segmented control that lets a user choose how to display contact fields.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Wallet export is coming soon. We'll let you know as soon as it's ready." : {
|
||||
"localizations" : {
|
||||
|
||||
@ -37,11 +37,13 @@ struct RootTabView: View {
|
||||
.sheet(isPresented: $showingShareSheet) {
|
||||
ShareCardView()
|
||||
}
|
||||
.preferredColorScheme(appState.preferredColorScheme)
|
||||
.fullScreenCover(isPresented: $showingOnboarding) {
|
||||
OnboardingView {
|
||||
appState.preferences.hasCompletedOnboarding = true
|
||||
showingOnboarding = false
|
||||
}
|
||||
.preferredColorScheme(appState.preferredColorScheme)
|
||||
}
|
||||
.onAppear {
|
||||
updateOnboardingPresentation()
|
||||
|
||||
@ -166,6 +166,10 @@ private struct CardContentView: View {
|
||||
|
||||
private var textColor: Color { Color.Text.primary }
|
||||
|
||||
private var userInfoTopPadding: CGFloat {
|
||||
card.headerLayout.contentOverlay == .none ? Design.Spacing.large : 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
contentOverlay
|
||||
@ -199,6 +203,7 @@ private struct CardContentView: View {
|
||||
.padding(.top, Design.Spacing.xxSmall)
|
||||
}
|
||||
}
|
||||
.padding(.top, userInfoTopPadding)
|
||||
|
||||
if !isCompact {
|
||||
Divider()
|
||||
@ -330,17 +335,82 @@ private struct LogoRectangleView: View {
|
||||
|
||||
private struct ContactFieldsListView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
@AppStorage("businessCard.contactFieldsViewMode") private var viewModeRawValue = ContactFieldsViewMode.list.rawValue
|
||||
let card: BusinessCard
|
||||
|
||||
private var viewMode: ContactFieldsViewMode {
|
||||
get { ContactFieldsViewMode(rawValue: viewModeRawValue) ?? .list }
|
||||
nonmutating set { viewModeRawValue = newValue.rawValue }
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
[
|
||||
GridItem(.flexible(), spacing: Design.Spacing.small),
|
||||
GridItem(.flexible(), spacing: Design.Spacing.small)
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
HStack {
|
||||
Text("Contact fields")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("View", selection: Binding(
|
||||
get: { viewMode },
|
||||
set: { viewMode = $0 }
|
||||
)) {
|
||||
ForEach(ContactFieldsViewMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 120)
|
||||
}
|
||||
|
||||
if viewMode == .list {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
ForEach(card.orderedContactFields) { field in
|
||||
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
|
||||
openField(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.small)
|
||||
.background(Color.AppBackground.base.opacity(0.45))
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
} else {
|
||||
LazyVGrid(columns: columns, spacing: Design.Spacing.small) {
|
||||
ForEach(card.orderedContactFields) { field in
|
||||
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
|
||||
openField(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openField(_ field: ContactField) {
|
||||
if let url = field.buildURL() {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ContactFieldsViewMode: String, CaseIterable, Identifiable {
|
||||
case list
|
||||
case grid
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .list: return "List"
|
||||
case .grid: return "Grid"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -350,34 +420,48 @@ private struct ContactFieldRowView: View {
|
||||
let themeColor: Color
|
||||
let action: () -> Void
|
||||
|
||||
private var valueText: String {
|
||||
field.displayValue
|
||||
}
|
||||
|
||||
private var labelText: String {
|
||||
field.title.isEmpty ? field.displayName : field.title
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
||||
HStack(alignment: .center, spacing: Design.Spacing.medium) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(themeColor.opacity(0.22))
|
||||
.frame(width: 34, height: 34)
|
||||
field.iconImage()
|
||||
.typography(.body)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
|
||||
.background(themeColor)
|
||||
.clipShape(.circle)
|
||||
.typography(.caption)
|
||||
.foregroundStyle(themeColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(field.displayValue)
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text(valueText)
|
||||
.typography(.subheading)
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(field.title.isEmpty ? field.displayName : field.title)
|
||||
Text(labelText)
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Text.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(Color.AppBackground.base.opacity(0.82))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@ -56,31 +56,14 @@ struct ContactFieldEditorSheet: View {
|
||||
}
|
||||
|
||||
private var isPhoneField: Bool {
|
||||
fieldType.id == "phone"
|
||||
}
|
||||
|
||||
private var isEmailField: Bool {
|
||||
fieldType.id == "email"
|
||||
}
|
||||
|
||||
private var isStrictURLField: Bool {
|
||||
fieldType.id == "website" || fieldType.id == "customLink" || fieldType.id == "calendly"
|
||||
ContactFieldInputRules.shouldFormatAsPhone(fieldType.id)
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
if isAddressField {
|
||||
return postalAddress.hasValue
|
||||
}
|
||||
if isPhoneField {
|
||||
return PhoneNumberText.isValid(value)
|
||||
}
|
||||
if isEmailField {
|
||||
return EmailText.isValid(value)
|
||||
}
|
||||
if isStrictURLField {
|
||||
return WebLinkText.isValid(value)
|
||||
}
|
||||
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
return ContactFieldInputRules.isValid(value, for: fieldType.id)
|
||||
}
|
||||
|
||||
private var isEditing: Bool {
|
||||
@ -119,22 +102,8 @@ struct ContactFieldEditorSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
if isPhoneField,
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!PhoneNumberText.isValid(value) {
|
||||
Text(String.localized("Enter a valid phone number"))
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Accent.red)
|
||||
} else if isEmailField,
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!EmailText.isValid(value) {
|
||||
Text(String.localized("Enter a valid email address"))
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Accent.red)
|
||||
} else if isStrictURLField,
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!WebLinkText.isValid(value) {
|
||||
Text(String.localized("Enter a valid web link"))
|
||||
if let validationMessage {
|
||||
Text(validationMessage)
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Accent.red)
|
||||
}
|
||||
@ -218,13 +187,7 @@ struct ContactFieldEditorSheet: View {
|
||||
// Save postal address as JSON
|
||||
onSave(postalAddress.encode(), title)
|
||||
} else {
|
||||
let valueToSave = isPhoneField
|
||||
? PhoneNumberText.normalizedForStorage(value)
|
||||
: isEmailField
|
||||
? EmailText.normalizedForStorage(value)
|
||||
: isStrictURLField
|
||||
? WebLinkText.normalizedForStorage(value)
|
||||
: value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let valueToSave = ContactFieldInputRules.normalizedForStorage(value, for: fieldType.id)
|
||||
onSave(valueToSave, title)
|
||||
}
|
||||
dismiss()
|
||||
@ -237,13 +200,21 @@ struct ContactFieldEditorSheet: View {
|
||||
|
||||
private var textContentType: UITextContentType? {
|
||||
switch fieldType.id {
|
||||
case "phone": return .telephoneNumber
|
||||
case "phone", "whatsapp", "signal": return .telephoneNumber
|
||||
case "email": return .emailAddress
|
||||
case "website": return .URL
|
||||
case "address": return .fullStreetAddress
|
||||
default: return .URL
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var validationMessage: String? {
|
||||
guard !isAddressField else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard !isValid else { return nil }
|
||||
return ContactFieldInputRules.validationMessage(for: fieldType.id)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Add Email") {
|
||||
|
||||
@ -47,6 +47,7 @@ struct OnboardingView: View {
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(appState.preferredColorScheme)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@ -30,6 +30,6 @@ struct AddedContactField: Identifiable, Equatable {
|
||||
return address.singleLineString
|
||||
}
|
||||
}
|
||||
return value
|
||||
return fieldType.formattedDisplayValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
@ -338,4 +338,67 @@ struct BusinessCardTests {
|
||||
#expect(received != nil)
|
||||
#expect(received?.isReceivedCard == true)
|
||||
}
|
||||
|
||||
@Test func addedContactFieldShortDisplayFormatsPhone() async throws {
|
||||
let field = AddedContactField(fieldType: .phone, value: "2145559898", title: "Cell")
|
||||
#expect(field.shortDisplayValue == "(214) 555-9898")
|
||||
}
|
||||
|
||||
@Test func addedContactFieldShortDisplayKeepsAddressSingleLine() async throws {
|
||||
let address = PostalAddress(
|
||||
street: "123 Main St",
|
||||
city: "Plano",
|
||||
state: "TX",
|
||||
postalCode: "75024"
|
||||
)
|
||||
let field = AddedContactField(fieldType: .address, value: address.encode(), title: "Work")
|
||||
|
||||
#expect(field.shortDisplayValue == "123 Main St, Plano, TX, 75024")
|
||||
}
|
||||
|
||||
@Test func contactFieldRulesNormalizeWebsiteWithoutScheme() async throws {
|
||||
let value = "company.com/team"
|
||||
#expect(ContactFieldInputRules.isValid(value, for: "website"))
|
||||
#expect(ContactFieldInputRules.normalizedForStorage(value, for: "website") == "https://company.com/team")
|
||||
}
|
||||
|
||||
@Test func contactFieldRulesValidateSocialUsernameOrLink() async throws {
|
||||
#expect(ContactFieldInputRules.isValid("mattbruce", for: "linkedIn"))
|
||||
#expect(ContactFieldInputRules.isValid("linkedin.com/in/mattbruce", for: "linkedIn"))
|
||||
#expect(
|
||||
ContactFieldInputRules.normalizedForStorage("mattbruce", for: "linkedIn")
|
||||
== "https://www.linkedin.com/in/mattbruce/"
|
||||
)
|
||||
#expect(
|
||||
ContactFieldInputRules.normalizedForStorage("https://linkedin.com/in/mattbruce", for: "linkedIn")
|
||||
== "https://www.linkedin.com/in/mattbruce/"
|
||||
)
|
||||
#expect(!ContactFieldInputRules.isValid("https://www.linkedin.com/company/acme", for: "linkedIn"))
|
||||
#expect(!ContactFieldInputRules.isValid("bad value with spaces", for: "linkedIn"))
|
||||
}
|
||||
|
||||
@Test func contactFieldRulesValidateMessagingPhoneFields() async throws {
|
||||
#expect(ContactFieldInputRules.isValid("+1 214 555 9898", for: "whatsapp"))
|
||||
#expect(ContactFieldInputRules.normalizedForStorage("+1 214 555 9898", for: "signal") == "+12145559898")
|
||||
#expect(!ContactFieldInputRules.isValid("123", for: "whatsapp"))
|
||||
}
|
||||
|
||||
@Test func contactFieldRulesValidatePaymentFields() async throws {
|
||||
#expect(ContactFieldInputRules.isValid("$mattbruce", for: "cashApp"))
|
||||
#expect(!ContactFieldInputRules.isValid("$bad tag", for: "cashApp"))
|
||||
#expect(ContactFieldInputRules.isValid("matt@example.com", for: "zelle"))
|
||||
#expect(ContactFieldInputRules.isValid("+1 214 555 9898", for: "zelle"))
|
||||
#expect(ContactFieldInputRules.isValid("paypal.me/mattbruce", for: "paypal"))
|
||||
}
|
||||
|
||||
@Test func contactFieldTypeBuildersUseServiceCorrectLinks() async throws {
|
||||
#expect(ContactFieldType.twitter.buildURL(value: "mattbruce")?.absoluteString == "https://x.com/mattbruce")
|
||||
#expect(ContactFieldType.snapchat.buildURL(value: "mattbruce")?.absoluteString == "https://www.snapchat.com/add/mattbruce")
|
||||
#expect(ContactFieldType.tiktok.buildURL(value: "mattbruce")?.absoluteString == "https://www.tiktok.com/@mattbruce")
|
||||
#expect(ContactFieldType.threads.buildURL(value: "mattbruce")?.absoluteString == "https://www.threads.net/@mattbruce")
|
||||
#expect(ContactFieldType.reddit.buildURL(value: "mattbruce")?.absoluteString == "https://www.reddit.com/user/mattbruce")
|
||||
#expect(ContactFieldType.telegram.buildURL(value: "@mattbruce")?.absoluteString == "https://t.me/mattbruce")
|
||||
#expect(ContactFieldType.venmo.buildURL(value: "@mattbruce")?.absoluteString == "https://venmo.com/u/mattbruce")
|
||||
#expect(ContactFieldType.cashApp.buildURL(value: "$mattbruce")?.absoluteString == "https://cash.app/$mattbruce")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user