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

This commit is contained in:
Matt Bruce 2026-01-09 08:10:12 -06:00
parent 6ee74c422b
commit 6d9c956c06
14 changed files with 847 additions and 1526 deletions

View File

@ -9,13 +9,6 @@ final class BusinessCard {
var role: String var role: String
var company: String var company: String
var label: String var label: String
var email: String
var emailLabel: String
var phone: String
var phoneLabel: String
var phoneExtension: String
var website: String
var location: String
var isDefault: Bool var isDefault: Bool
var themeName: String var themeName: String
var layoutStyleRawValue: String var layoutStyleRawValue: String
@ -37,27 +30,12 @@ final class BusinessCard {
var bio: String var bio: String
var accreditations: String var accreditations: String
// Social media
var linkedIn: String
var twitter: String
var instagram: String
var facebook: String
var tiktok: String
var github: String
var threads: String
var telegram: String
var venmo: String
var cashApp: String
// Custom links
var customLink1Title: String
var customLink1URL: String
var customLink2Title: String
var customLink2URL: String
// Profile photo stored as Data (JPEG) // Profile photo stored as Data (JPEG)
@Attribute(.externalStorage) var photoData: Data? @Attribute(.externalStorage) var photoData: Data?
// Cover photo for banner background stored as Data (JPEG)
@Attribute(.externalStorage) var coverPhotoData: Data?
// Company logo stored as Data (PNG) // Company logo stored as Data (PNG)
@Attribute(.externalStorage) var logoData: Data? @Attribute(.externalStorage) var logoData: Data?
@ -71,13 +49,6 @@ final class BusinessCard {
role: String = "", role: String = "",
company: String = "", company: String = "",
label: String = "Work", label: String = "Work",
email: String = "",
emailLabel: String = "Work",
phone: String = "",
phoneLabel: String = "Cell",
phoneExtension: String = "",
website: String = "",
location: String = "",
isDefault: Bool = false, isDefault: Bool = false,
themeName: String = "Coral", themeName: String = "Coral",
layoutStyleRawValue: String = "stacked", layoutStyleRawValue: String = "stacked",
@ -96,21 +67,8 @@ final class BusinessCard {
headline: String = "", headline: String = "",
bio: String = "", bio: String = "",
accreditations: String = "", accreditations: String = "",
linkedIn: String = "",
twitter: String = "",
instagram: String = "",
facebook: String = "",
tiktok: String = "",
github: String = "",
threads: String = "",
telegram: String = "",
venmo: String = "",
cashApp: String = "",
customLink1Title: String = "",
customLink1URL: String = "",
customLink2Title: String = "",
customLink2URL: String = "",
photoData: Data? = nil, photoData: Data? = nil,
coverPhotoData: Data? = nil,
logoData: Data? = nil logoData: Data? = nil
) { ) {
self.id = id self.id = id
@ -118,13 +76,6 @@ final class BusinessCard {
self.role = role self.role = role
self.company = company self.company = company
self.label = label self.label = label
self.email = email
self.emailLabel = emailLabel
self.phone = phone
self.phoneLabel = phoneLabel
self.phoneExtension = phoneExtension
self.website = website
self.location = location
self.isDefault = isDefault self.isDefault = isDefault
self.themeName = themeName self.themeName = themeName
self.layoutStyleRawValue = layoutStyleRawValue self.layoutStyleRawValue = layoutStyleRawValue
@ -143,21 +94,8 @@ final class BusinessCard {
self.headline = headline self.headline = headline
self.bio = bio self.bio = bio
self.accreditations = accreditations self.accreditations = accreditations
self.linkedIn = linkedIn
self.twitter = twitter
self.instagram = instagram
self.facebook = facebook
self.tiktok = tiktok
self.github = github
self.threads = threads
self.telegram = telegram
self.venmo = venmo
self.cashApp = cashApp
self.customLink1Title = customLink1Title
self.customLink1URL = customLink1URL
self.customLink2Title = customLink2Title
self.customLink2URL = customLink2URL
self.photoData = photoData self.photoData = photoData
self.coverPhotoData = coverPhotoData
self.logoData = logoData self.logoData = logoData
} }
@ -221,29 +159,6 @@ final class BusinessCard {
orderedContactFields.filter { $0.typeId == typeId } orderedContactFields.filter { $0.typeId == typeId }
} }
/// Returns true if the card has any contact fields
var hasContactFields: Bool {
!(contactFields ?? []).isEmpty
}
/// Returns true if the card has any social media links
var hasSocialLinks: Bool {
// Check new array first
let socialTypes: Set<String> = ["linkedIn", "twitter", "instagram", "facebook", "tiktok", "github", "threads", "telegram", "bluesky", "mastodon", "reddit", "youtube", "twitch", "snapchat", "pinterest"]
if orderedContactFields.contains(where: { socialTypes.contains($0.typeId) }) {
return true
}
// Fallback to legacy properties
return !linkedIn.isEmpty || !twitter.isEmpty || !instagram.isEmpty ||
!facebook.isEmpty || !tiktok.isEmpty || !github.isEmpty ||
!threads.isEmpty || !telegram.isEmpty
}
/// Returns true if the card has any payment links
var hasPaymentLinks: Bool {
!venmo.isEmpty || !cashApp.isEmpty
}
/// Returns accreditations as an array (stored as comma-separated string) /// Returns accreditations as an array (stored as comma-separated string)
var accreditationsList: [String] { var accreditationsList: [String] {
get { get {
@ -265,45 +180,14 @@ final class BusinessCard {
var parts: [String] = [] var parts: [String] = []
// Prefix (Dr, Mr, Ms, etc.) if !prefix.isEmpty { parts.append(prefix) }
if !prefix.isEmpty { if !firstName.isEmpty { parts.append(firstName) }
parts.append(prefix) if !preferredName.isEmpty { parts.append("\"\(preferredName)\"") }
} if !middleName.isEmpty { parts.append(middleName) }
if !lastName.isEmpty { parts.append(lastName) }
// First name if !suffix.isEmpty { parts.append(suffix) }
if !firstName.isEmpty { if !maidenName.isEmpty { parts.append("(\(maidenName))") }
parts.append(firstName) if !pronouns.isEmpty { parts.append("(\(pronouns))") }
}
// Preferred name in quotes
if !preferredName.isEmpty {
parts.append("\"\(preferredName)\"")
}
// Middle name
if !middleName.isEmpty {
parts.append(middleName)
}
// Last name
if !lastName.isEmpty {
parts.append(lastName)
}
// Suffix (Jr, III, etc.)
if !suffix.isEmpty {
parts.append(suffix)
}
// Maiden name in parentheses
if !maidenName.isEmpty {
parts.append("(\(maidenName))")
}
// Pronouns in parentheses
if !pronouns.isEmpty {
parts.append("(\(pronouns))")
}
return parts.joined(separator: " ") return parts.joined(separator: " ")
} }
@ -322,12 +206,6 @@ final class BusinessCard {
return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ") return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ")
} }
/// Returns true if the card has custom links
var hasCustomLinks: Bool {
(!customLink1Title.isEmpty && !customLink1URL.isEmpty) ||
(!customLink2Title.isEmpty && !customLink2URL.isEmpty)
}
var vCardPayload: String { var vCardPayload: String {
var lines = [ var lines = [
"BEGIN:VCARD", "BEGIN:VCARD",
@ -335,13 +213,12 @@ final class BusinessCard {
] ]
// N: Structured name - REQUIRED for proper contact import // N: Structured name - REQUIRED for proper contact import
// Format: LastName;FirstName;MiddleName;Prefix;Suffix
let structuredName = [lastName, firstName, middleName, prefix, suffix] let structuredName = [lastName, firstName, middleName, prefix, suffix]
.map { escapeVCardValue($0) } .map { escapeVCardValue($0) }
.joined(separator: ";") .joined(separator: ";")
lines.append("N:\(structuredName)") lines.append("N:\(structuredName)")
// FN: Formatted name - use simple name or display name // FN: Formatted name
let formattedName = simpleName.isEmpty ? displayName : simpleName let formattedName = simpleName.isEmpty ? displayName : simpleName
lines.append("FN:\(escapeVCardValue(formattedName))") lines.append("FN:\(escapeVCardValue(formattedName))")
@ -350,7 +227,7 @@ final class BusinessCard {
lines.append("NICKNAME:\(escapeVCardValue(preferredName))") lines.append("NICKNAME:\(escapeVCardValue(preferredName))")
} }
// ORG: Organization - can include department // ORG: Organization
if !company.isEmpty { if !company.isEmpty {
if !department.isEmpty { if !department.isEmpty {
lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))") lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))")
@ -364,7 +241,7 @@ final class BusinessCard {
lines.append("TITLE:\(escapeVCardValue(role))") lines.append("TITLE:\(escapeVCardValue(role))")
} }
// Contact fields from the new array (preferred) // Contact fields from the array
for field in orderedContactFields { for field in orderedContactFields {
let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines) let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue } guard !value.isEmpty else { continue }
@ -430,83 +307,18 @@ final class BusinessCard {
let label = field.title.isEmpty ? "OTHER" : field.title.uppercased() let label = field.title.isEmpty ? "OTHER" : field.title.uppercased()
lines.append("URL;TYPE=\(label):\(escapeVCardValue(value))") lines.append("URL;TYPE=\(label):\(escapeVCardValue(value))")
default: default:
// For unknown types, add as URL if it looks like a URL
if value.contains("://") || value.contains(".") { if value.contains("://") || value.contains(".") {
lines.append("URL:\(escapeVCardValue(value))") lines.append("URL:\(escapeVCardValue(value))")
} }
} }
} }
// Fallback to legacy properties if no contact fields exist
if orderedContactFields.isEmpty {
if !phone.isEmpty {
let typeLabel = phoneLabel.isEmpty ? "CELL" : phoneLabel.uppercased()
lines.append("TEL;TYPE=\(typeLabel):\(escapeVCardValue(phone))")
}
if !email.isEmpty {
let typeLabel = emailLabel.isEmpty ? "WORK" : emailLabel.uppercased()
lines.append("EMAIL;TYPE=\(typeLabel):\(escapeVCardValue(email))")
}
if !website.isEmpty {
lines.append("URL:\(escapeVCardValue(website))")
}
if !location.isEmpty {
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(location));;;;")
}
if !linkedIn.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(linkedIn))")
}
if !twitter.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(escapeVCardValue(twitter))")
}
if !instagram.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(escapeVCardValue(instagram))")
}
if !facebook.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=facebook:\(escapeVCardValue(facebook))")
}
if !tiktok.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=tiktok:\(escapeVCardValue(tiktok))")
}
if !github.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=github:\(escapeVCardValue(github))")
}
if !threads.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=threads:\(escapeVCardValue(threads))")
}
if !telegram.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=telegram:\(escapeVCardValue(telegram))")
}
if !venmo.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=venmo:\(escapeVCardValue(venmo))")
}
if !cashApp.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=cashapp:\(escapeVCardValue(cashApp))")
}
if !customLink1URL.isEmpty {
let label = customLink1Title.isEmpty ? "OTHER" : customLink1Title.uppercased()
lines.append("URL;TYPE=\(label):\(escapeVCardValue(customLink1URL))")
}
if !customLink2URL.isEmpty {
let label = customLink2Title.isEmpty ? "OTHER" : customLink2Title.uppercased()
lines.append("URL;TYPE=\(label):\(escapeVCardValue(customLink2URL))")
}
}
// NOTE: Bio, headline, and accreditations // NOTE: Bio, headline, and accreditations
var notes: [String] = [] var notes: [String] = []
if !headline.isEmpty { if !headline.isEmpty { notes.append(headline) }
notes.append(headline) if !bio.isEmpty { notes.append(bio) }
} if !accreditations.isEmpty { notes.append("Credentials: \(accreditations)") }
if !bio.isEmpty { if !pronouns.isEmpty { notes.append("Pronouns: \(pronouns)") }
notes.append(bio)
}
if !accreditations.isEmpty {
notes.append("Credentials: \(accreditations)")
}
if !pronouns.isEmpty {
notes.append("Pronouns: \(pronouns)")
}
if !notes.isEmpty { if !notes.isEmpty {
lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))") lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))")
} }
@ -528,63 +340,65 @@ final class BusinessCard {
extension BusinessCard { extension BusinessCard {
@MainActor @MainActor
static func createSamples(in context: ModelContext) { static func createSamples(in context: ModelContext) {
let samples = [ // Sample 1: Property Developer
BusinessCard( let sample1 = BusinessCard(
displayName: "Daniel Sullivan", displayName: "Daniel Sullivan",
role: "Property Developer", role: "Property Developer",
company: "WR Construction", company: "WR Construction",
label: "Work", label: "Work",
email: "daniel@wrconstruction.co",
phone: "+1 (214) 987-7810",
website: "wrconstruction.co",
location: "Dallas, TX",
isDefault: true, isDefault: true,
themeName: "Coral", themeName: "Coral",
layoutStyleRawValue: "split", layoutStyleRawValue: "split",
avatarSystemName: "person.crop.circle", avatarSystemName: "person.crop.circle",
pronouns: "he/him", pronouns: "he/him",
bio: "Building the future of Dallas real estate", bio: "Building the future of Dallas real estate"
linkedIn: "linkedin.com/in/danielsullivan" )
), context.insert(sample1)
BusinessCard( sample1.addContactField(.email, value: "daniel@wrconstruction.co", title: "Work")
sample1.addContactField(.phone, value: "+1 (214) 987-7810", title: "Cell")
sample1.addContactField(.website, value: "wrconstruction.co", title: "")
sample1.addContactField(.address, value: "Dallas, TX", title: "Work")
sample1.addContactField(.linkedIn, value: "linkedin.com/in/danielsullivan", title: "")
// Sample 2: Creative Lead
let sample2 = BusinessCard(
displayName: "Maya Chen", displayName: "Maya Chen",
role: "Creative Lead", role: "Creative Lead",
company: "Signal Studio", company: "Signal Studio",
label: "Creative", label: "Creative",
email: "maya@signal.studio",
phone: "+1 (312) 404-2211",
website: "signal.studio",
location: "Chicago, IL",
isDefault: false, isDefault: false,
themeName: "Midnight", themeName: "Midnight",
layoutStyleRawValue: "stacked", layoutStyleRawValue: "stacked",
avatarSystemName: "sparkles", avatarSystemName: "sparkles",
pronouns: "she/her", pronouns: "she/her",
bio: "Designing experiences that matter", bio: "Designing experiences that matter"
twitter: "twitter.com/mayachen", )
instagram: "instagram.com/mayachen.design" context.insert(sample2)
), sample2.addContactField(.email, value: "maya@signal.studio", title: "Work")
BusinessCard( sample2.addContactField(.phone, value: "+1 (312) 404-2211", title: "Cell")
sample2.addContactField(.website, value: "signal.studio", title: "")
sample2.addContactField(.address, value: "Chicago, IL", title: "Work")
sample2.addContactField(.twitter, value: "twitter.com/mayachen", title: "")
sample2.addContactField(.instagram, value: "instagram.com/mayachen.design", title: "")
// Sample 3: DJ
let sample3 = BusinessCard(
displayName: "DJ Michaels", displayName: "DJ Michaels",
role: "DJ", role: "DJ",
company: "Live Sessions", company: "Live Sessions",
label: "Music", label: "Music",
email: "dj@livesessions.fm",
phone: "+1 (646) 222-3300",
website: "livesessions.fm",
location: "New York, NY",
isDefault: false, isDefault: false,
themeName: "Ocean", themeName: "Ocean",
layoutStyleRawValue: "photo", layoutStyleRawValue: "photo",
avatarSystemName: "music.mic", avatarSystemName: "music.mic",
bio: "Bringing the beats to your events", bio: "Bringing the beats to your events"
instagram: "instagram.com/djmichaels",
tiktok: "tiktok.com/@djmichaels"
) )
] context.insert(sample3)
sample3.addContactField(.email, value: "dj@livesessions.fm", title: "Work")
for sample in samples { sample3.addContactField(.phone, value: "+1 (646) 222-3300", title: "Cell")
context.insert(sample) sample3.addContactField(.website, value: "livesessions.fm", title: "")
} sample3.addContactField(.address, value: "New York, NY", title: "Work")
sample3.addContactField(.instagram, value: "instagram.com/djmichaels", title: "")
sample3.addContactField(.tiktok, value: "tiktok.com/@djmichaels", title: "")
} }
} }

View File

@ -11,27 +11,19 @@ final class Contact {
var lastSharedDate: Date var lastSharedDate: Date
var cardLabel: String var cardLabel: String
// Enhanced name fields
var prefix: String
var firstName: String
var middleName: String
var lastName: String
var suffix: String
var maidenName: String
var preferredName: String
var pronouns: String
// Contact annotations // Contact annotations
var notes: String var notes: String
var tags: String // Comma-separated tags var tags: String // Comma-separated tags
var followUpDate: Date? var followUpDate: Date?
var email: String var email: String // Legacy single email (kept for migration/fallback)
var phone: String var phone: String // Legacy single phone (kept for migration/fallback)
var metAt: String // Where you met this person
// Multiple contact fields (phones, emails, links with labels)
@Relationship(deleteRule: .cascade, inverse: \ContactField.contact)
var contactFields: [ContactField]?
// If this is a received card (scanned from someone else) // If this is a received card (scanned from someone else)
var isReceivedCard: Bool var isReceivedCard: Bool
var receivedCardData: String // vCard data if received
// Profile photo // Profile photo
@Attribute(.externalStorage) var photoData: Data? @Attribute(.externalStorage) var photoData: Data?
@ -44,22 +36,12 @@ final class Contact {
avatarSystemName: String = "person.crop.circle", avatarSystemName: String = "person.crop.circle",
lastSharedDate: Date = .now, lastSharedDate: Date = .now,
cardLabel: String = "Work", cardLabel: String = "Work",
prefix: String = "",
firstName: String = "",
middleName: String = "",
lastName: String = "",
suffix: String = "",
maidenName: String = "",
preferredName: String = "",
pronouns: String = "",
notes: String = "", notes: String = "",
tags: String = "", tags: String = "",
followUpDate: Date? = nil, followUpDate: Date? = nil,
email: String = "", email: String = "",
phone: String = "", phone: String = "",
metAt: String = "",
isReceivedCard: Bool = false, isReceivedCard: Bool = false,
receivedCardData: String = "",
photoData: Data? = nil photoData: Data? = nil
) { ) {
self.id = id self.id = id
@ -69,22 +51,12 @@ final class Contact {
self.avatarSystemName = avatarSystemName self.avatarSystemName = avatarSystemName
self.lastSharedDate = lastSharedDate self.lastSharedDate = lastSharedDate
self.cardLabel = cardLabel self.cardLabel = cardLabel
self.prefix = prefix
self.firstName = firstName
self.middleName = middleName
self.lastName = lastName
self.suffix = suffix
self.maidenName = maidenName
self.preferredName = preferredName
self.pronouns = pronouns
self.notes = notes self.notes = notes
self.tags = tags self.tags = tags
self.followUpDate = followUpDate self.followUpDate = followUpDate
self.email = email self.email = email
self.phone = phone self.phone = phone
self.metAt = metAt
self.isReceivedCard = isReceivedCard self.isReceivedCard = isReceivedCard
self.receivedCardData = receivedCardData
self.photoData = photoData self.photoData = photoData
} }
@ -95,6 +67,31 @@ final class Contact {
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
} }
/// Returns contact fields sorted by order index
var sortedContactFields: [ContactField] {
(contactFields ?? []).sorted { $0.orderIndex < $1.orderIndex }
}
/// Filters contact fields by type ID
func fields(ofType typeId: String) -> [ContactField] {
sortedContactFields.filter { $0.typeId == typeId }
}
/// Gets all phone numbers
var phoneNumbers: [ContactField] {
fields(ofType: "phone")
}
/// Gets all email addresses
var emailAddresses: [ContactField] {
fields(ofType: "email")
}
/// Gets all links (website + custom links)
var links: [ContactField] {
sortedContactFields.filter { $0.typeId == "website" || $0.typeId == "customLink" }
}
/// Whether this contact has a follow-up reminder set /// Whether this contact has a follow-up reminder set
var hasFollowUp: Bool { var hasFollowUp: Bool {
followUpDate != nil followUpDate != nil
@ -120,8 +117,7 @@ extension Contact {
notes: "Met at the Austin fintech conference. Interested in property financing.", notes: "Met at the Austin fintech conference. Interested in property financing.",
tags: "finance, potential client", tags: "finance, potential client",
followUpDate: .now.addingTimeInterval(86400 * 7), followUpDate: .now.addingTimeInterval(86400 * 7),
email: "kevin.lennox@globalbank.com", email: "kevin.lennox@globalbank.com"
metAt: "Austin Fintech Conference 2026"
), ),
Contact( Contact(
name: "Jenny Wright", name: "Jenny Wright",
@ -132,8 +128,7 @@ extension Contact {
cardLabel: "Creative", cardLabel: "Creative",
notes: "Great portfolio. Could be a good hire or contractor.", notes: "Great portfolio. Could be a good hire or contractor.",
tags: "designer, talent", tags: "designer, talent",
email: "jenny@appfoundry.io", email: "jenny@appfoundry.io"
metAt: "LinkedIn"
), ),
Contact( Contact(
name: "Pip McDowell", name: "Pip McDowell",
@ -164,8 +159,7 @@ extension Contact {
lastSharedDate: .now.addingTimeInterval(-86400 * 7), lastSharedDate: .now.addingTimeInterval(-86400 * 7),
cardLabel: "Press", cardLabel: "Press",
notes: "Writing a piece on commercial real estate trends.", notes: "Writing a piece on commercial real estate trends.",
tags: "press, media", tags: "press, media"
metAt: "Industry panel"
) )
] ]
@ -176,7 +170,7 @@ extension Contact {
/// Creates a contact from received vCard data /// Creates a contact from received vCard data
static func fromVCard(_ vCardData: String) -> Contact { static func fromVCard(_ vCardData: String) -> Contact {
let contact = Contact(isReceivedCard: true, receivedCardData: vCardData) let contact = Contact(isReceivedCard: true)
// Parse vCard fields // Parse vCard fields
let lines = vCardData.components(separatedBy: "\n") let lines = vCardData.components(separatedBy: "\n")

View File

@ -23,6 +23,9 @@ final class ContactField {
/// Parent business card (inverse relationship) /// Parent business card (inverse relationship)
var card: BusinessCard? var card: BusinessCard?
/// Parent contact (inverse relationship for contact fields)
var contact: Contact?
init(typeId: String, value: String = "", title: String = "", orderIndex: Int = 0) { init(typeId: String, value: String = "", title: String = "", orderIndex: Int = 0) {
self.id = UUID() self.id = UUID()
self.typeId = typeId self.typeId = typeId

View File

@ -125,6 +125,7 @@ extension ContactFieldType {
Dictionary(grouping: allCases, by: { $0.category }) Dictionary(grouping: allCases, by: { $0.category })
} }
// MARK: - Contact // MARK: - Contact
static let phone = ContactFieldType( static let phone = ContactFieldType(

View File

@ -91,9 +91,6 @@
}, },
"Are you sure you want to delete this contact?" : { "Are you sure you want to delete this contact?" : {
},
"Bio" : {
}, },
"Calendly Link" : { "Calendly Link" : {
@ -105,6 +102,7 @@
}, },
"Card style" : { "Card style" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -154,6 +152,9 @@
}, },
"Choose a card in the My Cards tab to start sharing." : { "Choose a card in the My Cards tab to start sharing." : {
},
"Company" : {
}, },
"Company Website" : { "Company Website" : {
@ -163,6 +164,9 @@
}, },
"Contact" : { "Contact" : {
},
"Contact Fields" : {
}, },
"Cover photo" : { "Cover photo" : {
@ -192,9 +196,6 @@
}, },
"Create your first card" : { "Create your first card" : {
},
"Custom Links" : {
}, },
"Customize your card" : { "Customize your card" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -224,6 +225,9 @@
}, },
"Delete Field" : { "Delete Field" : {
},
"Department" : {
}, },
"Design and share polished digital business cards for every context." : { "Design and share polished digital business cards for every context." : {
@ -246,14 +250,14 @@
"Email or Username" : { "Email or Username" : {
}, },
"Ext." : { "First Name" : {
},
"Headline" : {
}, },
"Here are some suggestions for your title:" : { "Here are some suggestions for your title:" : {
},
"Hold each field below to re-order it" : {
}, },
"Hold your phone near another device to share instantly. NFC setup is on the way." : { "Hold your phone near another device to share instantly. NFC setup is on the way." : {
"localizations" : { "localizations" : {
@ -302,17 +306,23 @@
} }
} }
}, },
"Label" : { "Job Title" : {
},
"Last Name" : {
}, },
"Link" : { "Link" : {
}, },
"Location" : { "Maiden Name" : {
}, },
"Messaging" : { "Messaging" : {
},
"Middle Name" : {
}, },
"More..." : { "More..." : {
@ -362,9 +372,6 @@
}, },
"Personal details" : { "Personal details" : {
},
"Phone" : {
}, },
"Phone Number" : { "Phone Number" : {
@ -424,12 +431,21 @@
}, },
"Portfolio" : { "Portfolio" : {
},
"Preferred Name" : {
},
"Prefix (e.g. Dr., Mr., Ms.)" : {
}, },
"Preview card" : { "Preview card" : {
}, },
"Profile Link" : { "Profile Link" : {
},
"Pronouns (e.g. she/her)" : {
}, },
"QR Code Scanned" : { "QR Code Scanned" : {
@ -521,6 +537,9 @@
}, },
"Social Media" : { "Social Media" : {
},
"Suffix (e.g. Jr., III)" : {
}, },
"Support & Funding" : { "Support & Funding" : {
@ -530,9 +549,6 @@
}, },
"Tap a field below to add it" : { "Tap a field below to add it" : {
},
"Tap to edit this accreditation" : {
}, },
"Tap to share" : { "Tap to share" : {
"localizations" : { "localizations" : {
@ -669,9 +685,6 @@
} }
} }
} }
},
"Website" : {
}, },
"Website URL" : { "Website URL" : {

View File

@ -10,25 +10,35 @@ struct WatchSyncService {
} }
/// Syncs the given cards to the shared App Group for watchOS to read /// Syncs the given cards to the shared App Group for watchOS to read
@MainActor
static func syncCards(_ cards: [BusinessCard]) { static func syncCards(_ cards: [BusinessCard]) {
guard let defaults = sharedDefaults else { return } guard let defaults = sharedDefaults else { return }
let syncableCards = cards.map { card in let syncableCards = cards.map { card in
SyncableCard( // Get first email, phone, website, location from contact fields
let email = card.firstContactField(ofType: "email")?.value ?? ""
let phone = card.firstContactField(ofType: "phone")?.value ?? ""
let website = card.firstContactField(ofType: "website")?.value ?? ""
let location = card.firstContactField(ofType: "address")?.value ?? ""
let linkedIn = card.firstContactField(ofType: "linkedIn")?.value ?? ""
let twitter = card.firstContactField(ofType: "twitter")?.value ?? ""
let instagram = card.firstContactField(ofType: "instagram")?.value ?? ""
return SyncableCard(
id: card.id, id: card.id,
displayName: card.displayName, displayName: card.displayName,
role: card.role, role: card.role,
company: card.company, company: card.company,
email: card.email, email: email,
phone: card.phone, phone: phone,
website: card.website, website: website,
location: card.location, location: location,
isDefault: card.isDefault, isDefault: card.isDefault,
pronouns: card.pronouns, pronouns: card.pronouns,
bio: card.bio, bio: card.bio,
linkedIn: card.linkedIn, linkedIn: linkedIn,
twitter: card.twitter, twitter: twitter,
instagram: card.instagram instagram: instagram
) )
} }

View File

@ -84,8 +84,8 @@ final class ContactsStore: ContactTracking {
phone: String = "", phone: String = "",
notes: String = "", notes: String = "",
tags: String = "", tags: String = "",
metAt: String = "", followUpDate: Date? = nil,
followUpDate: Date? = nil contactFields: [ContactField] = []
) { ) {
let contact = Contact( let contact = Contact(
name: name, name: name,
@ -96,10 +96,19 @@ final class ContactsStore: ContactTracking {
tags: tags, tags: tags,
followUpDate: followUpDate, followUpDate: followUpDate,
email: email, email: email,
phone: phone, phone: phone
metAt: metAt
) )
modelContext.insert(contact) modelContext.insert(contact)
// Add contact fields if provided
if !contactFields.isEmpty {
for field in contactFields {
field.contact = contact
modelContext.insert(field)
}
contact.contactFields = contactFields
}
saveContext() saveContext()
fetchContacts() fetchContacts()
} }

View File

@ -37,20 +37,30 @@ private struct CardBannerView: View {
var body: some View { var body: some View {
ZStack { ZStack {
// Gradient background // Background: cover photo or gradient
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(height: Design.CardSize.bannerHeight)
.clipped()
} else {
// Fallback gradient
LinearGradient( LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor], colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
}
// Company logo // Company logo overlay
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) { if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(height: Design.CardSize.logoSize) .frame(height: Design.CardSize.logoSize)
} else if !card.company.isEmpty { } else if card.coverPhotoData == nil && !card.company.isEmpty {
// Only show company initial if no cover photo and no logo
Text(card.company.prefix(1).uppercased()) Text(card.company.prefix(1).uppercased())
.font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded)) .font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded))
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium)) .foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
@ -173,10 +183,6 @@ private struct ContactFieldsListView: View {
} }
} }
// Legacy properties fallback (for backward compatibility)
if card.orderedContactFields.isEmpty {
LegacyContactDetailsView(card: card)
}
} }
} }
} }
@ -226,144 +232,6 @@ private struct ContactFieldRowView: View {
} }
} }
/// Legacy view for cards that haven't migrated to the new contact fields array
private struct LegacyContactDetailsView: View {
@Environment(\.openURL) private var openURL
let card: BusinessCard
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
if !card.email.isEmpty {
LegacyContactRowView(
fieldType: .email,
value: card.email,
label: card.emailLabel
) {
if let url = URL(string: "mailto:\(card.email)") {
openURL(url)
}
}
}
if !card.phone.isEmpty {
let phoneDisplay = card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)"
LegacyContactRowView(
fieldType: .phone,
value: phoneDisplay,
label: card.phoneLabel
) {
let cleaned = card.phone.replacing(try! Regex("[^0-9+]"), with: "")
if let url = URL(string: "tel:\(cleaned)") {
openURL(url)
}
}
}
if !card.website.isEmpty {
LegacyContactRowView(
fieldType: .website,
value: card.website,
label: nil
) {
let urlString = card.website.hasPrefix("http") ? card.website : "https://\(card.website)"
if let url = URL(string: urlString) {
openURL(url)
}
}
}
if !card.location.isEmpty {
LegacyContactRowView(
fieldType: .address,
value: card.location,
label: nil
) {
let query = card.location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
if let url = URL(string: "maps://?q=\(query)") {
openURL(url)
}
}
}
// Legacy social links
LegacySocialLinksView(card: card)
}
}
}
private struct LegacyContactRowView: View {
let fieldType: ContactFieldType
let value: String
let label: String?
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
fieldType.iconImage()
.font(.body)
.foregroundStyle(.white)
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
.background(fieldType.iconColor)
.clipShape(.circle)
VStack(alignment: .leading, spacing: 0) {
Text(value)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
if let label, !label.isEmpty {
Text(label)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
} else {
Text(fieldType.displayName)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
private struct LegacySocialLinksView: View {
@Environment(\.openURL) private var openURL
let card: BusinessCard
private var socialItems: [(fieldType: ContactFieldType, value: String)] {
var items: [(ContactFieldType, String)] = []
if !card.linkedIn.isEmpty { items.append((.linkedIn, card.linkedIn)) }
if !card.twitter.isEmpty { items.append((.twitter, card.twitter)) }
if !card.instagram.isEmpty { items.append((.instagram, card.instagram)) }
if !card.facebook.isEmpty { items.append((.facebook, card.facebook)) }
if !card.tiktok.isEmpty { items.append((.tiktok, card.tiktok)) }
if !card.github.isEmpty { items.append((.github, card.github)) }
if !card.threads.isEmpty { items.append((.threads, card.threads)) }
if !card.telegram.isEmpty { items.append((.telegram, card.telegram)) }
return items
}
var body: some View {
ForEach(socialItems, id: \.fieldType.id) { item in
LegacyContactRowView(
fieldType: item.fieldType,
value: item.value,
label: nil
) {
if let url = item.fieldType.urlBuilder(item.value) {
openURL(url)
}
}
}
}
}
// MARK: - Preview // MARK: - Preview
#Preview { #Preview {
@ -373,20 +241,20 @@ private struct LegacySocialLinksView: View {
displayName: "Matt Bruce", displayName: "Matt Bruce",
role: "Lead iOS Developer", role: "Lead iOS Developer",
company: "Toyota", company: "Toyota",
email: "matt.bruce@toyota.com",
emailLabel: "Work",
phone: "+1 (214) 755-1043",
phoneLabel: "Cell",
website: "toyota.com",
location: "Dallas, TX",
themeName: "Coral", themeName: "Coral",
layoutStyleRawValue: "stacked", layoutStyleRawValue: "stacked",
headline: "Building the future of mobility", headline: "Building the future of mobility"
linkedIn: "linkedin.com/in/mattbruce",
twitter: "twitter.com/mattbruce"
) )
context.insert(card) context.insert(card)
// Add contact fields
card.addContactField(.email, value: "matt.bruce@toyota.com", title: "Work")
card.addContactField(.phone, value: "+1 (214) 755-1043", title: "Cell")
card.addContactField(.website, value: "toyota.com", title: "")
card.addContactField(.address, value: "Dallas, TX", title: "Work")
card.addContactField(.linkedIn, value: "linkedin.com/in/mattbruce", title: "")
card.addContactField(.twitter, value: "twitter.com/mattbruce", title: "")
return BusinessCardView(card: card) return BusinessCardView(card: card)
.padding() .padding()
.background(Color.AppBackground.base) .background(Color.AppBackground.base)

File diff suppressed because it is too large Load Diff

View File

@ -66,33 +66,8 @@ struct ContactDetailView: View {
} }
} }
// Contact info card // Contact info card - shows both legacy fields and new contact fields
if !contact.email.isEmpty || !contact.phone.isEmpty { ContactInfoCard(contact: contact, openURL: openURL)
VStack(spacing: 0) {
if !contact.email.isEmpty {
ContactInfoRow(
icon: "envelope.fill",
value: contact.email,
label: "Home",
action: { openURL("mailto:\(contact.email)") }
)
if !contact.phone.isEmpty {
Divider()
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
}
}
if !contact.phone.isEmpty {
ContactInfoRow(
icon: "phone.fill",
value: contact.phone,
label: "Cell",
action: { openURL("tel:\(contact.phone)") }
)
}
}
.background(Color.AppBackground.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
// Notes section // Notes section
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
@ -295,6 +270,74 @@ private struct TagPill: View {
} }
} }
// MARK: - Contact Info Card
private struct ContactInfoCard: View {
let contact: Contact
let openURL: (String) -> Void
private var allContactItems: [(icon: String, value: String, label: String, urlScheme: String)] {
var items: [(icon: String, value: String, label: String, urlScheme: String)] = []
// Add from new contact fields (phones)
for field in contact.phoneNumbers {
items.append((icon: "phone.fill", value: field.value, label: field.title.isEmpty ? "Phone" : field.title, urlScheme: "tel:"))
}
// Add from new contact fields (emails)
for field in contact.emailAddresses {
items.append((icon: "envelope.fill", value: field.value, label: field.title.isEmpty ? "Email" : field.title, urlScheme: "mailto:"))
}
// Add from new contact fields (links)
for field in contact.links {
let url = field.value.hasPrefix("http") ? field.value : "https://\(field.value)"
items.append((icon: "link", value: field.value, label: field.title.isEmpty ? "Link" : field.title, urlScheme: url.hasPrefix("http") ? "" : "https://"))
}
// Legacy fallback: if no new fields, show legacy email/phone
if items.isEmpty {
if !contact.phone.isEmpty {
items.append((icon: "phone.fill", value: contact.phone, label: "Cell", urlScheme: "tel:"))
}
if !contact.email.isEmpty {
items.append((icon: "envelope.fill", value: contact.email, label: "Email", urlScheme: "mailto:"))
}
}
return items
}
var body: some View {
if !allContactItems.isEmpty {
VStack(spacing: 0) {
ForEach(allContactItems.indices, id: \.self) { index in
let item = allContactItems[index]
ContactInfoRow(
icon: item.icon,
value: item.value,
label: item.label,
action: {
if item.urlScheme.isEmpty {
openURL(item.value)
} else {
openURL("\(item.urlScheme)\(item.value)")
}
}
)
if index < allContactItems.count - 1 {
Divider()
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
}
}
}
.background(Color.AppBackground.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
}
}
// MARK: - Contact Info Row // MARK: - Contact Info Row
private struct ContactInfoRow: View { private struct ContactInfoRow: View {

View File

@ -6,14 +6,22 @@ struct AddContactSheet: View {
@Environment(AppState.self) private var appState @Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
// Name fields
@State private var firstName = "" @State private var firstName = ""
@State private var lastName = "" @State private var lastName = ""
@State private var phone = ""
@State private var email = "" // Professional fields
@State private var link = ""
@State private var jobTitle = "" @State private var jobTitle = ""
@State private var company = "" @State private var company = ""
// Contact fields with labels (multiple allowed)
@State private var phoneEntries: [LabeledEntry] = [LabeledEntry(label: "Cell", value: "")]
@State private var emailEntries: [LabeledEntry] = [LabeledEntry(label: "Work", value: "")]
@State private var linkEntries: [LabeledEntry] = [LabeledEntry(label: "Website", value: "")]
// Notes
@State private var notes = ""
private var canSave: Bool { private var canSave: Bool {
!firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@ -28,47 +36,102 @@ struct AddContactSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { Form {
VStack(spacing: 0) {
// Name section // Name section
VStack(alignment: .leading, spacing: Design.Spacing.medium) { Section {
EditorTextField(placeholder: String.localized("First name"), text: $firstName) TextField(String.localized("First name"), text: $firstName)
.textContentType(.givenName) .textContentType(.givenName)
EditorTextField(placeholder: String.localized("Last name"), text: $lastName) TextField(String.localized("Last name"), text: $lastName)
.textContentType(.familyName) .textContentType(.familyName)
} }
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
// Contact section // Professional section (moved before contact)
VStack(alignment: .leading, spacing: Design.Spacing.medium) { Section {
EditorTextField(placeholder: String.localized("Phone"), text: $phone) TextField(String.localized("Job title"), text: $jobTitle)
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
EditorTextField(placeholder: String.localized("Email"), text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
EditorTextField(placeholder: String.localized("Link"), text: $link)
.keyboardType(.URL)
.textContentType(.URL)
.textInputAutocapitalization(.never)
}
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
// Professional section
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
EditorTextField(placeholder: String.localized("Job title"), text: $jobTitle)
.textContentType(.jobTitle) .textContentType(.jobTitle)
EditorTextField(placeholder: String.localized("Company"), text: $company) TextField(String.localized("Company"), text: $company)
.textContentType(.organizationName) .textContentType(.organizationName)
} }
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated) // Phone numbers section
Section {
ForEach($phoneEntries) { $entry in
LabeledFieldRow(
entry: $entry,
valuePlaceholder: "+1 (555) 123-4567",
labelSuggestions: ["Cell", "Work", "Home", "Main"],
keyboardType: .phonePad
)
}
.onDelete { indexSet in
phoneEntries.remove(atOffsets: indexSet)
}
Button {
phoneEntries.append(LabeledEntry(label: "Cell", value: ""))
} label: {
Label(String.localized("Add phone"), systemImage: "plus.circle.fill")
}
} header: {
Text("Phone")
}
// Email section
Section {
ForEach($emailEntries) { $entry in
LabeledFieldRow(
entry: $entry,
valuePlaceholder: "email@example.com",
labelSuggestions: ["Work", "Personal", "Other"],
keyboardType: .emailAddress,
autocapitalization: .never
)
}
.onDelete { indexSet in
emailEntries.remove(atOffsets: indexSet)
}
Button {
emailEntries.append(LabeledEntry(label: "Work", value: ""))
} label: {
Label(String.localized("Add email"), systemImage: "plus.circle.fill")
}
} header: {
Text("Email")
}
// Links section
Section {
ForEach($linkEntries) { $entry in
LabeledFieldRow(
entry: $entry,
valuePlaceholder: "https://example.com",
labelSuggestions: ["Website", "Portfolio", "LinkedIn", "Other"],
keyboardType: .URL,
autocapitalization: .never
)
}
.onDelete { indexSet in
linkEntries.remove(atOffsets: indexSet)
}
Button {
linkEntries.append(LabeledEntry(label: "Website", value: ""))
} label: {
Label(String.localized("Add link"), systemImage: "plus.circle.fill")
}
} header: {
Text("Links")
}
// Notes section
Section {
TextField(String.localized("Notes about this contact..."), text: $notes, axis: .vertical)
.lineLimit(3...8)
} header: {
Text("Notes")
} }
} }
.background(Color.AppBackground.base)
.navigationTitle(String.localized("New contact")) .navigationTitle(String.localized("New contact"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -88,27 +151,83 @@ struct AddContactSheet: View {
} }
private func saveContact() { private func saveContact() {
// Build contact fields from entries
var contactFields: [ContactField] = []
var orderIndex = 0
// Add phone entries
for entry in phoneEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let field = ContactField(typeId: "phone", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
contactFields.append(field)
orderIndex += 1
}
// Add email entries
for entry in emailEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let field = ContactField(typeId: "email", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
contactFields.append(field)
orderIndex += 1
}
// Add link entries
for entry in linkEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let field = ContactField(typeId: "customLink", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
contactFields.append(field)
orderIndex += 1
}
appState.contactsStore.createContact( appState.contactsStore.createContact(
name: fullName, name: fullName,
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines), role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
company: company.trimmingCharacters(in: .whitespacesAndNewlines), company: company.trimmingCharacters(in: .whitespacesAndNewlines),
email: email.trimmingCharacters(in: .whitespacesAndNewlines), notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines) contactFields: contactFields
) )
dismiss() dismiss()
} }
} }
// MARK: - Shared Editor TextField // MARK: - Labeled Entry Model
private struct EditorTextField: View { private struct LabeledEntry: Identifiable {
let placeholder: String let id = UUID()
@Binding var text: String var label: String
var value: String
}
// MARK: - Labeled Field Row
private struct LabeledFieldRow: View {
@Binding var entry: LabeledEntry
let valuePlaceholder: String
let labelSuggestions: [String]
var keyboardType: UIKeyboardType = .default
var autocapitalization: TextInputAutocapitalization = .sentences
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { HStack(spacing: Design.Spacing.medium) {
TextField(placeholder, text: $text) // Label picker
Divider() Menu {
ForEach(labelSuggestions, id: \.self) { suggestion in
Button(suggestion) {
entry.label = suggestion
}
}
} label: {
HStack(spacing: Design.Spacing.xSmall) {
Text(entry.label)
.foregroundStyle(Color.accentColor)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(Color.secondary)
}
}
.frame(width: 80, alignment: .leading)
// Value field
TextField(valuePlaceholder, text: $entry.value)
.keyboardType(keyboardType)
.textInputAutocapitalization(autocapitalization)
} }
} }
} }

View File

@ -263,14 +263,6 @@ struct BusinessCardTests {
// Tests for high priority features // Tests for high priority features
@Test func cardSocialLinksDetection() async throws {
let card = BusinessCard(displayName: "Test")
#expect(!card.hasSocialLinks)
card.linkedIn = "linkedin.com/in/test"
#expect(card.hasSocialLinks)
}
@Test func contactNotesAndTags() async throws { @Test func contactNotesAndTags() async throws {
let contact = Contact( let contact = Contact(
name: "Test Contact", name: "Test Contact",

View File

@ -16,9 +16,10 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
- Tap the **edit icon** (pencil) in the top right to edit the current card - Tap the **edit icon** (pencil) in the top right to edit the current card
- Tap the **plus icon** to create a new card - Tap the **plus icon** to create a new card
- Set a default card for sharing - Set a default card for sharing
- **Modern card design**: Banner with company logo, overlapping profile photo, clean contact rows - **Modern card design**: Banner with optional cover photo, company logo, overlapping profile photo, clean contact rows
- **Profile photos**: Add a photo from your library or use an icon - **Profile photos**: Add a headshot from your library or use an icon
- **Company logos**: Upload a logo to display on your card's banner - **Cover photos**: Add a custom banner background image
- **Company logos**: Upload a logo to overlay on your card's banner
- **Rich profiles**: First/middle/last name, prefix, maiden name, preferred name, pronouns, headline, bio, accreditations - **Rich profiles**: First/middle/last name, prefix, maiden name, preferred name, pronouns, headline, bio, accreditations
- **Clickable contact fields**: Tap any field to call, email, open link, or launch app - **Clickable contact fields**: Tap any field to call, email, open link, or launch app

View File

@ -53,8 +53,8 @@ App-specific extensions are in `Design/DesignConstants.swift`:
- Rich fields: pronouns, bio, headline, accreditations (comma-separated tags) - Rich fields: pronouns, bio, headline, accreditations (comma-separated tags)
- **Dynamic contact fields**: `@Relationship` to array of `ContactField` objects - **Dynamic contact fields**: `@Relationship` to array of `ContactField` objects
- Legacy fields: social links (LinkedIn, Twitter, etc.) for backward compatibility - Legacy fields: social links (LinkedIn, Twitter, etc.) for backward compatibility
- Photo: `photoData` and `logoData` stored with `@Attribute(.externalStorage)` - Photos: `photoData` (profile), `coverPhotoData` (banner background), `logoData` (company logo) stored with `@Attribute(.externalStorage)`
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks`, `orderedContactFields` - Computed: `theme`, `layoutStyle`, `vCardPayload`, `orderedContactFields`
- Helper methods: `addContactField`, `removeContactField`, `reorderContactFields` - Helper methods: `addContactField`, `removeContactField`, `reorderContactFields`
- `Models/ContactField.swift` — SwiftData model for dynamic contact fields: - `Models/ContactField.swift` — SwiftData model for dynamic contact fields:
@ -112,7 +112,7 @@ Main screens:
Feature views: Feature views:
- `Views/BusinessCardView.swift` — card display with layouts - `Views/BusinessCardView.swift` — card display with layouts
- `Views/CardEditorView.swift` — create/edit cards with PhotosPicker - `Views/CardEditorView.swift` — create/edit cards with PhotosPicker for 3 image types (profile, cover, logo)
- `Views/ContactDetailView.swift` — full contact view with annotations - `Views/ContactDetailView.swift` — full contact view with annotations
- `Views/QRScannerView.swift` — camera-based QR scanner - `Views/QRScannerView.swift` — camera-based QR scanner
- `Views/QRCodeView.swift` — QR code image generator - `Views/QRCodeView.swift` — QR code image generator