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/
|
*.xcworkspace/xcuserdata/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
build/
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.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",
|
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
|
||||||
|
|||||||
@ -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" : {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,19 +335,84 @@ 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) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
ForEach(card.orderedContactFields) { field in
|
HStack {
|
||||||
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
|
Text("Contact fields")
|
||||||
if let url = field.buildURL() {
|
.typography(.caption)
|
||||||
openURL(url)
|
.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ContactFieldRowView: View {
|
private struct ContactFieldRowView: View {
|
||||||
@ -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) {
|
||||||
field.iconImage()
|
ZStack {
|
||||||
.typography(.body)
|
Circle()
|
||||||
.foregroundStyle(.white)
|
.fill(themeColor.opacity(0.22))
|
||||||
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
|
.frame(width: 34, height: 34)
|
||||||
.background(themeColor)
|
field.iconImage()
|
||||||
.clipShape(.circle)
|
.typography(.caption)
|
||||||
|
.foregroundStyle(themeColor)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -47,6 +47,7 @@ struct OnboardingView: View {
|
|||||||
refreshPermissionStatuses()
|
refreshPermissionStatuses()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(appState.preferredColorScheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@ -30,6 +30,6 @@ struct AddedContactField: Identifiable, Equatable {
|
|||||||
return address.singleLineString
|
return address.singleLineString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value
|
return fieldType.formattedDisplayValue(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user