Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-11 10:49:00 -06:00
parent 6423bc3b76
commit e6f6684c88
10 changed files with 654 additions and 92 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ Package.resolved
*.xcworkspace/xcuserdata/ *.xcworkspace/xcuserdata/
DerivedData/ DerivedData/
xcuserdata/ xcuserdata/
build/
# macOS # macOS
.DS_Store .DS_Store

View 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) }
}
}

View File

@ -193,7 +193,7 @@ extension ContactFieldType {
valuePlaceholder: "linkedin.com/in/username", valuePlaceholder: "linkedin.com/in/username",
titleSuggestions: ["Connect with me on LinkedIn"], titleSuggestions: ["Connect with me on LinkedIn"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "linkedin.com") } urlBuilder: { ContactFieldInputRules.linkedInProfileURL(for: $0) }
) )
static let twitter = ContactFieldType( static let twitter = ContactFieldType(
@ -207,7 +207,7 @@ extension ContactFieldType {
valuePlaceholder: "x.com/username", valuePlaceholder: "x.com/username",
titleSuggestions: ["Follow me on X"], titleSuggestions: ["Follow me on X"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "x.com") } urlBuilder: { buildXProfileURL($0) }
) )
static let instagram = ContactFieldType( static let instagram = ContactFieldType(
@ -221,7 +221,7 @@ extension ContactFieldType {
valuePlaceholder: "instagram.com/username", valuePlaceholder: "instagram.com/username",
titleSuggestions: ["Follow me on Instagram"], titleSuggestions: ["Follow me on Instagram"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "instagram.com") } urlBuilder: { buildInstagramProfileURL($0) }
) )
static let facebook = ContactFieldType( static let facebook = ContactFieldType(
@ -249,7 +249,7 @@ extension ContactFieldType {
valuePlaceholder: "tiktok.com/@username", valuePlaceholder: "tiktok.com/@username",
titleSuggestions: ["Follow me on TikTok"], titleSuggestions: ["Follow me on TikTok"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "tiktok.com") } urlBuilder: { buildTikTokProfileURL($0) }
) )
static let threads = ContactFieldType( static let threads = ContactFieldType(
@ -263,7 +263,7 @@ extension ContactFieldType {
valuePlaceholder: "threads.net/@username", valuePlaceholder: "threads.net/@username",
titleSuggestions: ["Follow me on Threads"], titleSuggestions: ["Follow me on Threads"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "threads.net") } urlBuilder: { buildThreadsProfileURL($0) }
) )
static let youtube = ContactFieldType( static let youtube = ContactFieldType(
@ -277,7 +277,7 @@ extension ContactFieldType {
valuePlaceholder: "youtube.com/@channel", valuePlaceholder: "youtube.com/@channel",
titleSuggestions: ["Subscribe to my channel"], titleSuggestions: ["Subscribe to my channel"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "youtube.com") } urlBuilder: { buildYouTubeProfileURL($0) }
) )
static let snapchat = ContactFieldType( static let snapchat = ContactFieldType(
@ -290,7 +290,7 @@ extension ContactFieldType {
valuePlaceholder: "snapchat.com/add/username", valuePlaceholder: "snapchat.com/add/username",
titleSuggestions: ["Add me on Snapchat"], titleSuggestions: ["Add me on Snapchat"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "snapchat.com") } urlBuilder: { buildSnapchatProfileURL($0) }
) )
static let pinterest = ContactFieldType( static let pinterest = ContactFieldType(
@ -331,7 +331,7 @@ extension ContactFieldType {
valuePlaceholder: "bsky.app/profile/username", valuePlaceholder: "bsky.app/profile/username",
titleSuggestions: ["Follow me on Bluesky"], titleSuggestions: ["Follow me on Bluesky"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "bsky.app") } urlBuilder: { buildBlueskyProfileURL($0) }
) )
static let mastodon = ContactFieldType( static let mastodon = ContactFieldType(
@ -368,7 +368,7 @@ extension ContactFieldType {
valuePlaceholder: "reddit.com/user/username", valuePlaceholder: "reddit.com/user/username",
titleSuggestions: ["Follow me on Reddit"], titleSuggestions: ["Follow me on Reddit"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "reddit.com") } urlBuilder: { buildRedditProfileURL($0) }
) )
// MARK: - Developer // MARK: - Developer
@ -410,7 +410,7 @@ extension ContactFieldType {
valuePlaceholder: "stackoverflow.com/users/id", valuePlaceholder: "stackoverflow.com/users/id",
titleSuggestions: ["Ask me on Stack Overflow"], titleSuggestions: ["Ask me on Stack Overflow"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "stackoverflow.com") } urlBuilder: { buildStackOverflowProfileURL($0) }
) )
// MARK: - Messaging // MARK: - Messaging
@ -426,12 +426,7 @@ extension ContactFieldType {
valuePlaceholder: "t.me/username", valuePlaceholder: "t.me/username",
titleSuggestions: ["Connect with me on Telegram"], titleSuggestions: ["Connect with me on Telegram"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { value in urlBuilder: { buildTelegramProfileURL($0) }
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( static let whatsapp = ContactFieldType(
@ -477,7 +472,7 @@ extension ContactFieldType {
valuePlaceholder: "discord.gg/invite", valuePlaceholder: "discord.gg/invite",
titleSuggestions: ["Join my Discord"], titleSuggestions: ["Join my Discord"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "discord.gg") } urlBuilder: { buildDiscordInviteURL($0) }
) )
static let slack = ContactFieldType( static let slack = ContactFieldType(
@ -491,7 +486,7 @@ extension ContactFieldType {
valuePlaceholder: "yourworkspace.slack.com", valuePlaceholder: "yourworkspace.slack.com",
titleSuggestions: ["Join our Slack"], titleSuggestions: ["Join our Slack"],
keyboardType: .URL, keyboardType: .URL,
urlBuilder: { buildSocialURL($0, webBase: "slack.com") } urlBuilder: { buildSlackWorkspaceURL($0) }
) )
static let matrix = ContactFieldType( static let matrix = ContactFieldType(
@ -526,10 +521,7 @@ extension ContactFieldType {
valuePlaceholder: "@username", valuePlaceholder: "@username",
titleSuggestions: ["Pay via Venmo"], titleSuggestions: ["Pay via Venmo"],
keyboardType: .default, keyboardType: .default,
urlBuilder: { value in urlBuilder: { buildVenmoProfileURL($0) }
let username = value.hasPrefix("@") ? String(value.dropFirst()) : value
return URL(string: "venmo://users/\(username)")
}
) )
static let cashApp = ContactFieldType( static let cashApp = ContactFieldType(
@ -542,10 +534,7 @@ extension ContactFieldType {
valuePlaceholder: "$cashtag", valuePlaceholder: "$cashtag",
titleSuggestions: ["Pay via Cash App"], titleSuggestions: ["Pay via Cash App"],
keyboardType: .default, keyboardType: .default,
urlBuilder: { value in urlBuilder: { buildCashAppProfileURL($0) }
let cashtag = value.hasPrefix("$") ? String(value.dropFirst()) : value
return URL(string: "cashapp://cash.app/$\(cashtag)")
}
) )
static let paypal = ContactFieldType( static let paypal = ContactFieldType(
@ -558,7 +547,7 @@ extension ContactFieldType {
valuePlaceholder: "paypal.me/username", valuePlaceholder: "paypal.me/username",
titleSuggestions: ["Pay via PayPal"], titleSuggestions: ["Pay via PayPal"],
keyboardType: .emailAddress, keyboardType: .emailAddress,
urlBuilder: { URL(string: "https://paypal.me/\($0)") } urlBuilder: { buildPayPalURL($0) }
) )
static let zelle = ContactFieldType( static let zelle = ContactFieldType(
@ -689,4 +678,183 @@ nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL
return URL(string: "https://\(webBase)/\(username)") 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 // MARK: - Phone Text Utilities

View File

@ -205,6 +205,10 @@
}, },
"Contact" : { "Contact" : {
},
"Contact fields" : {
"comment" : "A label displayed above a list of a user's contact fields.",
"isCommentAutoGenerated" : true
}, },
"Could not load card: %@" : { "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.", "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" : { "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" : { "Expires in 7 days" : {
"comment" : "A notice displayed below an App Clip URL that indicates when it will expire.", "comment" : "A notice displayed below an App Clip URL that indicates when it will expire.",
@ -905,6 +949,10 @@
}, },
"Username/Link" : { "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." : { "Wallet export is coming soon. We'll let you know as soon as it's ready." : {
"localizations" : { "localizations" : {

View File

@ -37,11 +37,13 @@ struct RootTabView: View {
.sheet(isPresented: $showingShareSheet) { .sheet(isPresented: $showingShareSheet) {
ShareCardView() ShareCardView()
} }
.preferredColorScheme(appState.preferredColorScheme)
.fullScreenCover(isPresented: $showingOnboarding) { .fullScreenCover(isPresented: $showingOnboarding) {
OnboardingView { OnboardingView {
appState.preferences.hasCompletedOnboarding = true appState.preferences.hasCompletedOnboarding = true
showingOnboarding = false showingOnboarding = false
} }
.preferredColorScheme(appState.preferredColorScheme)
} }
.onAppear { .onAppear {
updateOnboardingPresentation() updateOnboardingPresentation()

View File

@ -166,6 +166,10 @@ private struct CardContentView: View {
private var textColor: Color { Color.Text.primary } private var textColor: Color { Color.Text.primary }
private var userInfoTopPadding: CGFloat {
card.headerLayout.contentOverlay == .none ? Design.Spacing.large : 0
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
contentOverlay contentOverlay
@ -199,6 +203,7 @@ private struct CardContentView: View {
.padding(.top, Design.Spacing.xxSmall) .padding(.top, Design.Spacing.xxSmall)
} }
} }
.padding(.top, userInfoTopPadding)
if !isCompact { if !isCompact {
Divider() Divider()
@ -330,17 +335,82 @@ private struct LogoRectangleView: View {
private struct ContactFieldsListView: View { private struct ContactFieldsListView: View {
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@AppStorage("businessCard.contactFieldsViewMode") private var viewModeRawValue = ContactFieldsViewMode.list.rawValue
let card: BusinessCard 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 { 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) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
ForEach(card.orderedContactFields) { field in ForEach(card.orderedContactFields) { field in
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) { 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() { if let url = field.buildURL() {
openURL(url) 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 themeColor: Color
let action: () -> Void let action: () -> Void
private var valueText: String {
field.displayValue
}
private var labelText: String {
field.title.isEmpty ? field.displayName : field.title
}
var body: some View { var body: some View {
Button(action: action) { 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() field.iconImage()
.typography(.body) .typography(.caption)
.foregroundStyle(.white) .foregroundStyle(themeColor)
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize) }
.background(themeColor)
.clipShape(.circle)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(field.displayValue) Text(valueText)
.typography(.subheading) .typography(.subheading)
.foregroundStyle(Color.Text.primary) .foregroundStyle(Color.Text.primary)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(1)
Text(field.title.isEmpty ? field.displayName : field.title) Text(labelText)
.typography(.caption) .typography(.caption)
.foregroundStyle(Color.Text.secondary) .foregroundStyle(Color.Text.secondary)
.lineLimit(1) .lineLimit(1)
} }
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
Image(systemName: "chevron.right")
.typography(.caption)
.foregroundStyle(Color.Text.tertiary)
} }
.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) .contentShape(.rect)
} }
.buttonStyle(.plain) .buttonStyle(.plain)

View File

@ -56,31 +56,14 @@ struct ContactFieldEditorSheet: View {
} }
private var isPhoneField: Bool { private var isPhoneField: Bool {
fieldType.id == "phone" ContactFieldInputRules.shouldFormatAsPhone(fieldType.id)
}
private var isEmailField: Bool {
fieldType.id == "email"
}
private var isStrictURLField: Bool {
fieldType.id == "website" || fieldType.id == "customLink" || fieldType.id == "calendly"
} }
private var isValid: Bool { private var isValid: Bool {
if isAddressField { if isAddressField {
return postalAddress.hasValue return postalAddress.hasValue
} }
if isPhoneField { return ContactFieldInputRules.isValid(value, for: fieldType.id)
return PhoneNumberText.isValid(value)
}
if isEmailField {
return EmailText.isValid(value)
}
if isStrictURLField {
return WebLinkText.isValid(value)
}
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
private var isEditing: Bool { private var isEditing: Bool {
@ -119,22 +102,8 @@ struct ContactFieldEditorSheet: View {
} }
} }
if isPhoneField, if let validationMessage {
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, Text(validationMessage)
!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"))
.typography(.caption) .typography(.caption)
.foregroundStyle(Color.Accent.red) .foregroundStyle(Color.Accent.red)
} }
@ -218,13 +187,7 @@ struct ContactFieldEditorSheet: View {
// Save postal address as JSON // Save postal address as JSON
onSave(postalAddress.encode(), title) onSave(postalAddress.encode(), title)
} else { } else {
let valueToSave = isPhoneField let valueToSave = ContactFieldInputRules.normalizedForStorage(value, for: fieldType.id)
? PhoneNumberText.normalizedForStorage(value)
: isEmailField
? EmailText.normalizedForStorage(value)
: isStrictURLField
? WebLinkText.normalizedForStorage(value)
: value.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(valueToSave, title) onSave(valueToSave, title)
} }
dismiss() dismiss()
@ -237,13 +200,21 @@ struct ContactFieldEditorSheet: View {
private var textContentType: UITextContentType? { private var textContentType: UITextContentType? {
switch fieldType.id { switch fieldType.id {
case "phone": return .telephoneNumber case "phone", "whatsapp", "signal": return .telephoneNumber
case "email": return .emailAddress case "email": return .emailAddress
case "website": return .URL case "website": return .URL
case "address": return .fullStreetAddress 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") { #Preview("Add Email") {

View File

@ -47,6 +47,7 @@ struct OnboardingView: View {
refreshPermissionStatuses() refreshPermissionStatuses()
} }
} }
.preferredColorScheme(appState.preferredColorScheme)
} }
@ViewBuilder @ViewBuilder

View File

@ -30,6 +30,6 @@ struct AddedContactField: Identifiable, Equatable {
return address.singleLineString return address.singleLineString
} }
} }
return value return fieldType.formattedDisplayValue(value)
} }
} }

View File

@ -338,4 +338,67 @@ struct BusinessCardTests {
#expect(received != nil) #expect(received != nil)
#expect(received?.isReceivedCard == true) #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")
}
} }