import Foundation import Contacts /// Represents a shared card fetched from CloudKit for display in the App Clip. struct SharedCardSnapshot: Sendable { struct ContactInfoRow: Identifiable, Sendable { enum Kind: Sendable { case phone case email case website case address case social case note } let id = UUID() let kind: Kind let value: String let label: String var systemImage: String { switch kind { case .phone: return "phone.fill" case .email: return "envelope.fill" case .website: return "globe" case .address: return "location.fill" case .social: return "link" case .note: return "note.text" } } } let recordName: String let vCardData: String let displayName: String let role: String let company: String let photoData: Data? let contactInfoRows: [ContactInfoRow] init( recordName: String, vCardData: String, displayName: String? = nil, role: String? = nil, company: String? = nil, photoData: Data? = nil ) { self.recordName = recordName self.vCardData = vCardData // Parse display fields from vCard let lines = vCardData.components(separatedBy: .newlines) let parsedDisplayName = Self.parseField("FN:", from: lines) ?? String(localized: "Contact") let parsedRole = Self.parseField("TITLE:", from: lines) ?? "" let parsedCompany = Self.parseField("ORG:", from: lines)? .components(separatedBy: ";").first ?? "" let parsedPhotoData = Self.parsePhoto(from: lines) let cleanedDisplayName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" self.displayName = cleanedDisplayName.isEmpty ? parsedDisplayName : cleanedDisplayName self.role = role ?? parsedRole self.company = company ?? parsedCompany self.photoData = photoData ?? parsedPhotoData self.contactInfoRows = Self.parseContactInfoRows(from: lines, vCardData: vCardData) } private static func parseField(_ prefix: String, from lines: [String]) -> String? { lines.first { $0.hasPrefix(prefix) }? .dropFirst(prefix.count) .trimmingCharacters(in: .whitespaces) } private static func parsePhoto(from lines: [String]) -> Data? { // Handles PHOTO on a single line. Folded/multiline photos are out of scope here. guard let photoLine = lines.first(where: { $0.uppercased().hasPrefix("PHOTO") }), let base64Start = photoLine.firstIndex(of: ":") else { return nil } let valueStart = photoLine.index(after: base64Start) let base64String = String(photoLine[valueStart...]).trimmingCharacters(in: .whitespacesAndNewlines) return Data(base64Encoded: base64String) } private static func parseContactInfoRows(from lines: [String], vCardData: String) -> [ContactInfoRow] { var rows = parseContactRowsWithContactsFramework(vCardData: vCardData) var existingKeys = Set(rows.map { dedupeKey(for: $0) }) let useManualFallback = rows.isEmpty for line in lines { guard let separatorIndex = line.firstIndex(of: ":") else { continue } let metadata = String(line[.. [ContactInfoRow] { guard let data = vCardData.data(using: .utf8), let contact = try? CNContactVCardSerialization.contacts(with: data).first else { return [] } var rows: [ContactInfoRow] = [] for phone in contact.phoneNumbers { let value = phone.value.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { continue } rows.append( ContactInfoRow( kind: .phone, value: value, label: normalizeLabel(localizedLabel(phone.label)) ) ) } for email in contact.emailAddresses { let value = String(email.value).trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { continue } rows.append( ContactInfoRow( kind: .email, value: value, label: normalizeLabel(localizedLabel(email.label, fallback: "Email")) ) ) } for address in contact.postalAddresses { let value = CNPostalAddressFormatter.string(from: address.value, style: .mailingAddress) .replacingOccurrences(of: "\n", with: ", ") .trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { continue } rows.append( ContactInfoRow( kind: .address, value: value, label: normalizeLabel(localizedLabel(address.label, fallback: "Address")) ) ) } for urlAddress in contact.urlAddresses { let value = String(urlAddress.value).trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { continue } rows.append( ContactInfoRow( kind: .website, value: value, label: normalizeLabel(localizedLabel(urlAddress.label, fallback: "Website")) ) ) } let noteValue = contact.note.trimmingCharacters(in: .whitespacesAndNewlines) if !noteValue.isEmpty { rows.append( ContactInfoRow( kind: .note, value: noteValue, label: "Note" ) ) } return rows } private static func localizedLabel(_ label: String?, fallback: String = "Work") -> String { guard let label else { return fallback } return CNLabeledValue.localizedString(forLabel: label) } private static func dedupeKey(for row: ContactInfoRow) -> String { "\(row.kind)-\(row.value.lowercased())" } private static func extractType(from metadata: String) -> String? { let parameters = metadata.components(separatedBy: ";") for parameter in parameters { let parts = parameter.components(separatedBy: "=") guard parts.count == 2, parts[0].uppercased() == "TYPE" else { continue } let rawType = parts[1].components(separatedBy: ",").first ?? parts[1] return rawType.trimmingCharacters(in: .whitespacesAndNewlines) } return nil } private static func normalizeLabel(_ raw: String) -> String { switch raw.uppercased() { case "CELL": return "Cell" case "WORK": return "Work" case "HOME": return "Home" case "PERSONAL": return "Personal" default: return raw.capitalized } } private static func normalizeSocialLabel(_ raw: String) -> String { switch raw.lowercased() { case "linkedin": return "LinkedIn" case "twitter": return "X" case "tiktok": return "TikTok" case "youtube": return "YouTube" case "cashapp": return "Cash App" default: return raw.capitalized } } private static func formatAddress(_ adrValue: String) -> String { let parts = adrValue .split(separator: ";", omittingEmptySubsequences: false) .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } guard parts.count >= 7 else { return adrValue } let street = parts[2] let city = parts[3] let state = parts[4] let postalCode = parts[5] let country = parts[6] let locality = [city, state, postalCode] .filter { !$0.isEmpty } .joined(separator: " ") return [street, locality, country] .filter { !$0.isEmpty } .joined(separator: ", ") } }