diff --git a/.gitignore b/.gitignore index 6a3bc6b..cfc94a8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ Package.resolved *.xcworkspace/xcuserdata/ DerivedData/ xcuserdata/ +build/ # macOS .DS_Store diff --git a/BusinessCard/Models/ContactFieldInputRules.swift b/BusinessCard/Models/ContactFieldInputRules.swift new file mode 100644 index 0000000..f4a6533 --- /dev/null +++ b/BusinessCard/Models/ContactFieldInputRules.swift @@ -0,0 +1,224 @@ +import Foundation + +enum ContactFieldInputRules { + private static let phoneFieldIDs: Set = ["phone", "whatsapp", "signal"] + private static let strictWebLinkFieldIDs: Set = ["website", "customLink", "calendly"] + private static let usernameOrLinkFieldIDs: Set = [ + "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) } + } +} diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift index feeff91..d3bd377 100644 --- a/BusinessCard/Models/ContactFieldType.swift +++ b/BusinessCard/Models/ContactFieldType.swift @@ -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 diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 2da1523..8b7c8b7 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -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" : { diff --git a/BusinessCard/Views/Features/AppShell/RootTabView.swift b/BusinessCard/Views/Features/AppShell/RootTabView.swift index 33f3431..f9968ac 100644 --- a/BusinessCard/Views/Features/AppShell/RootTabView.swift +++ b/BusinessCard/Views/Features/AppShell/RootTabView.swift @@ -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() diff --git a/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift b/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift index 01f7c1b..ce3b412 100644 --- a/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift +++ b/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift @@ -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,19 +335,84 @@ 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) { - ForEach(card.orderedContactFields) { field in - ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) { - if let url = field.buildURL() { - openURL(url) + 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" + } + } } private struct ContactFieldRowView: View { @@ -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) { - field.iconImage() - .typography(.body) - .foregroundStyle(.white) - .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize) - .background(themeColor) - .clipShape(.circle) + HStack(alignment: .center, spacing: Design.Spacing.medium) { + ZStack { + Circle() + .fill(themeColor.opacity(0.22)) + .frame(width: 34, height: 34) + field.iconImage() + .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) diff --git a/BusinessCard/Views/Features/Cards/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Features/Cards/Sheets/ContactFieldEditorSheet.swift index b6d1c57..d1887d4 100644 --- a/BusinessCard/Views/Features/Cards/Sheets/ContactFieldEditorSheet.swift +++ b/BusinessCard/Views/Features/Cards/Sheets/ContactFieldEditorSheet.swift @@ -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") { diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingView.swift b/BusinessCard/Views/Features/Onboarding/OnboardingView.swift index c99d596..96cf160 100644 --- a/BusinessCard/Views/Features/Onboarding/OnboardingView.swift +++ b/BusinessCard/Views/Features/Onboarding/OnboardingView.swift @@ -47,6 +47,7 @@ struct OnboardingView: View { refreshPermissionStatuses() } } + .preferredColorScheme(appState.preferredColorScheme) } @ViewBuilder diff --git a/BusinessCard/Views/Shared/Components/AddedContactField.swift b/BusinessCard/Views/Shared/Components/AddedContactField.swift index 6d6dca8..1f3d9aa 100644 --- a/BusinessCard/Views/Shared/Components/AddedContactField.swift +++ b/BusinessCard/Views/Shared/Components/AddedContactField.swift @@ -30,6 +30,6 @@ struct AddedContactField: Identifiable, Equatable { return address.singleLineString } } - return value + return fieldType.formattedDisplayValue(value) } } diff --git a/BusinessCardTests/BusinessCardTests.swift b/BusinessCardTests/BusinessCardTests.swift index 160a5fd..facfd41 100644 --- a/BusinessCardTests/BusinessCardTests.swift +++ b/BusinessCardTests/BusinessCardTests.swift @@ -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") + } }