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 company: 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 themeName: String
var layoutStyleRawValue: String
@ -37,27 +30,12 @@ final class BusinessCard {
var bio: 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)
@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)
@Attribute(.externalStorage) var logoData: Data?
@ -71,13 +49,6 @@ final class BusinessCard {
role: String = "",
company: String = "",
label: String = "Work",
email: String = "",
emailLabel: String = "Work",
phone: String = "",
phoneLabel: String = "Cell",
phoneExtension: String = "",
website: String = "",
location: String = "",
isDefault: Bool = false,
themeName: String = "Coral",
layoutStyleRawValue: String = "stacked",
@ -96,21 +67,8 @@ final class BusinessCard {
headline: String = "",
bio: 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,
coverPhotoData: Data? = nil,
logoData: Data? = nil
) {
self.id = id
@ -118,13 +76,6 @@ final class BusinessCard {
self.role = role
self.company = company
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.themeName = themeName
self.layoutStyleRawValue = layoutStyleRawValue
@ -143,21 +94,8 @@ final class BusinessCard {
self.headline = headline
self.bio = bio
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.coverPhotoData = coverPhotoData
self.logoData = logoData
}
@ -221,29 +159,6 @@ final class BusinessCard {
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)
var accreditationsList: [String] {
get {
@ -265,45 +180,14 @@ final class BusinessCard {
var parts: [String] = []
// Prefix (Dr, Mr, Ms, etc.)
if !prefix.isEmpty {
parts.append(prefix)
}
// First name
if !firstName.isEmpty {
parts.append(firstName)
}
// 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))")
}
if !prefix.isEmpty { parts.append(prefix) }
if !firstName.isEmpty { parts.append(firstName) }
if !preferredName.isEmpty { parts.append("\"\(preferredName)\"") }
if !middleName.isEmpty { parts.append(middleName) }
if !lastName.isEmpty { parts.append(lastName) }
if !suffix.isEmpty { parts.append(suffix) }
if !maidenName.isEmpty { parts.append("(\(maidenName))") }
if !pronouns.isEmpty { parts.append("(\(pronouns))") }
return parts.joined(separator: " ")
}
@ -321,12 +205,6 @@ final class BusinessCard {
let parts = [firstName, lastName].filter { !$0.isEmpty }
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 lines = [
@ -335,13 +213,12 @@ final class BusinessCard {
]
// N: Structured name - REQUIRED for proper contact import
// Format: LastName;FirstName;MiddleName;Prefix;Suffix
let structuredName = [lastName, firstName, middleName, prefix, suffix]
.map { escapeVCardValue($0) }
.joined(separator: ";")
lines.append("N:\(structuredName)")
// FN: Formatted name - use simple name or display name
// FN: Formatted name
let formattedName = simpleName.isEmpty ? displayName : simpleName
lines.append("FN:\(escapeVCardValue(formattedName))")
@ -350,7 +227,7 @@ final class BusinessCard {
lines.append("NICKNAME:\(escapeVCardValue(preferredName))")
}
// ORG: Organization - can include department
// ORG: Organization
if !company.isEmpty {
if !department.isEmpty {
lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))")
@ -364,7 +241,7 @@ final class BusinessCard {
lines.append("TITLE:\(escapeVCardValue(role))")
}
// Contact fields from the new array (preferred)
// Contact fields from the array
for field in orderedContactFields {
let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
@ -430,83 +307,18 @@ final class BusinessCard {
let label = field.title.isEmpty ? "OTHER" : field.title.uppercased()
lines.append("URL;TYPE=\(label):\(escapeVCardValue(value))")
default:
// For unknown types, add as URL if it looks like a URL
if value.contains("://") || value.contains(".") {
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
var notes: [String] = []
if !headline.isEmpty {
notes.append(headline)
}
if !bio.isEmpty {
notes.append(bio)
}
if !accreditations.isEmpty {
notes.append("Credentials: \(accreditations)")
}
if !pronouns.isEmpty {
notes.append("Pronouns: \(pronouns)")
}
if !headline.isEmpty { notes.append(headline) }
if !bio.isEmpty { notes.append(bio) }
if !accreditations.isEmpty { notes.append("Credentials: \(accreditations)") }
if !pronouns.isEmpty { notes.append("Pronouns: \(pronouns)") }
if !notes.isEmpty {
lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))")
}
@ -528,63 +340,65 @@ final class BusinessCard {
extension BusinessCard {
@MainActor
static func createSamples(in context: ModelContext) {
let samples = [
BusinessCard(
displayName: "Daniel Sullivan",
role: "Property Developer",
company: "WR Construction",
label: "Work",
email: "daniel@wrconstruction.co",
phone: "+1 (214) 987-7810",
website: "wrconstruction.co",
location: "Dallas, TX",
isDefault: true,
themeName: "Coral",
layoutStyleRawValue: "split",
avatarSystemName: "person.crop.circle",
pronouns: "he/him",
bio: "Building the future of Dallas real estate",
linkedIn: "linkedin.com/in/danielsullivan"
),
BusinessCard(
displayName: "Maya Chen",
role: "Creative Lead",
company: "Signal Studio",
label: "Creative",
email: "maya@signal.studio",
phone: "+1 (312) 404-2211",
website: "signal.studio",
location: "Chicago, IL",
isDefault: false,
themeName: "Midnight",
layoutStyleRawValue: "stacked",
avatarSystemName: "sparkles",
pronouns: "she/her",
bio: "Designing experiences that matter",
twitter: "twitter.com/mayachen",
instagram: "instagram.com/mayachen.design"
),
BusinessCard(
displayName: "DJ Michaels",
role: "DJ",
company: "Live Sessions",
label: "Music",
email: "dj@livesessions.fm",
phone: "+1 (646) 222-3300",
website: "livesessions.fm",
location: "New York, NY",
isDefault: false,
themeName: "Ocean",
layoutStyleRawValue: "photo",
avatarSystemName: "music.mic",
bio: "Bringing the beats to your events",
instagram: "instagram.com/djmichaels",
tiktok: "tiktok.com/@djmichaels"
)
]
for sample in samples {
context.insert(sample)
}
// Sample 1: Property Developer
let sample1 = BusinessCard(
displayName: "Daniel Sullivan",
role: "Property Developer",
company: "WR Construction",
label: "Work",
isDefault: true,
themeName: "Coral",
layoutStyleRawValue: "split",
avatarSystemName: "person.crop.circle",
pronouns: "he/him",
bio: "Building the future of Dallas real estate"
)
context.insert(sample1)
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",
role: "Creative Lead",
company: "Signal Studio",
label: "Creative",
isDefault: false,
themeName: "Midnight",
layoutStyleRawValue: "stacked",
avatarSystemName: "sparkles",
pronouns: "she/her",
bio: "Designing experiences that matter"
)
context.insert(sample2)
sample2.addContactField(.email, value: "maya@signal.studio", title: "Work")
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",
role: "DJ",
company: "Live Sessions",
label: "Music",
isDefault: false,
themeName: "Ocean",
layoutStyleRawValue: "photo",
avatarSystemName: "music.mic",
bio: "Bringing the beats to your events"
)
context.insert(sample3)
sample3.addContactField(.email, value: "dj@livesessions.fm", title: "Work")
sample3.addContactField(.phone, value: "+1 (646) 222-3300", title: "Cell")
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 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
var notes: String
var tags: String // Comma-separated tags
var followUpDate: Date?
var email: String
var phone: String
var metAt: String // Where you met this person
var email: String // Legacy single email (kept for migration/fallback)
var phone: String // Legacy single phone (kept for migration/fallback)
// 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)
var isReceivedCard: Bool
var receivedCardData: String // vCard data if received
// Profile photo
@Attribute(.externalStorage) var photoData: Data?
@ -44,22 +36,12 @@ final class Contact {
avatarSystemName: String = "person.crop.circle",
lastSharedDate: Date = .now,
cardLabel: String = "Work",
prefix: String = "",
firstName: String = "",
middleName: String = "",
lastName: String = "",
suffix: String = "",
maidenName: String = "",
preferredName: String = "",
pronouns: String = "",
notes: String = "",
tags: String = "",
followUpDate: Date? = nil,
email: String = "",
phone: String = "",
metAt: String = "",
isReceivedCard: Bool = false,
receivedCardData: String = "",
photoData: Data? = nil
) {
self.id = id
@ -69,22 +51,12 @@ final class Contact {
self.avatarSystemName = avatarSystemName
self.lastSharedDate = lastSharedDate
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.tags = tags
self.followUpDate = followUpDate
self.email = email
self.phone = phone
self.metAt = metAt
self.isReceivedCard = isReceivedCard
self.receivedCardData = receivedCardData
self.photoData = photoData
}
@ -95,6 +67,31 @@ final class Contact {
.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
var hasFollowUp: Bool {
followUpDate != nil
@ -120,8 +117,7 @@ extension Contact {
notes: "Met at the Austin fintech conference. Interested in property financing.",
tags: "finance, potential client",
followUpDate: .now.addingTimeInterval(86400 * 7),
email: "kevin.lennox@globalbank.com",
metAt: "Austin Fintech Conference 2026"
email: "kevin.lennox@globalbank.com"
),
Contact(
name: "Jenny Wright",
@ -132,8 +128,7 @@ extension Contact {
cardLabel: "Creative",
notes: "Great portfolio. Could be a good hire or contractor.",
tags: "designer, talent",
email: "jenny@appfoundry.io",
metAt: "LinkedIn"
email: "jenny@appfoundry.io"
),
Contact(
name: "Pip McDowell",
@ -164,8 +159,7 @@ extension Contact {
lastSharedDate: .now.addingTimeInterval(-86400 * 7),
cardLabel: "Press",
notes: "Writing a piece on commercial real estate trends.",
tags: "press, media",
metAt: "Industry panel"
tags: "press, media"
)
]
@ -176,7 +170,7 @@ extension Contact {
/// Creates a contact from received vCard data
static func fromVCard(_ vCardData: String) -> Contact {
let contact = Contact(isReceivedCard: true, receivedCardData: vCardData)
let contact = Contact(isReceivedCard: true)
// Parse vCard fields
let lines = vCardData.components(separatedBy: "\n")

View File

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

View File

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

View File

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

View File

@ -10,25 +10,35 @@ struct WatchSyncService {
}
/// Syncs the given cards to the shared App Group for watchOS to read
@MainActor
static func syncCards(_ cards: [BusinessCard]) {
guard let defaults = sharedDefaults else { return }
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,
displayName: card.displayName,
role: card.role,
company: card.company,
email: card.email,
phone: card.phone,
website: card.website,
location: card.location,
email: email,
phone: phone,
website: website,
location: location,
isDefault: card.isDefault,
pronouns: card.pronouns,
bio: card.bio,
linkedIn: card.linkedIn,
twitter: card.twitter,
instagram: card.instagram
linkedIn: linkedIn,
twitter: twitter,
instagram: instagram
)
}

View File

@ -84,8 +84,8 @@ final class ContactsStore: ContactTracking {
phone: String = "",
notes: String = "",
tags: String = "",
metAt: String = "",
followUpDate: Date? = nil
followUpDate: Date? = nil,
contactFields: [ContactField] = []
) {
let contact = Contact(
name: name,
@ -96,10 +96,19 @@ final class ContactsStore: ContactTracking {
tags: tags,
followUpDate: followUpDate,
email: email,
phone: phone,
metAt: metAt
phone: phone
)
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()
fetchContacts()
}

View File

@ -37,20 +37,30 @@ private struct CardBannerView: View {
var body: some View {
ZStack {
// Gradient background
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// 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(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
// Company logo
// Company logo overlay
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.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())
.font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded))
.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
#Preview {
@ -373,20 +241,20 @@ private struct LegacySocialLinksView: View {
displayName: "Matt Bruce",
role: "Lead iOS Developer",
company: "Toyota",
email: "matt.bruce@toyota.com",
emailLabel: "Work",
phone: "+1 (214) 755-1043",
phoneLabel: "Cell",
website: "toyota.com",
location: "Dallas, TX",
themeName: "Coral",
layoutStyleRawValue: "stacked",
headline: "Building the future of mobility",
linkedIn: "linkedin.com/in/mattbruce",
twitter: "twitter.com/mattbruce"
headline: "Building the future of mobility"
)
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)
.padding()
.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
if !contact.email.isEmpty || !contact.phone.isEmpty {
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))
}
// Contact info card - shows both legacy fields and new contact fields
ContactInfoCard(contact: contact, openURL: openURL)
// Notes section
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
private struct ContactInfoRow: View {

View File

@ -6,14 +6,22 @@ struct AddContactSheet: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
// Name fields
@State private var firstName = ""
@State private var lastName = ""
@State private var phone = ""
@State private var email = ""
@State private var link = ""
// Professional fields
@State private var jobTitle = ""
@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 {
!firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@ -28,47 +36,102 @@ struct AddContactSheet: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
// Name section
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
EditorTextField(placeholder: String.localized("First name"), text: $firstName)
.textContentType(.givenName)
EditorTextField(placeholder: String.localized("Last name"), text: $lastName)
.textContentType(.familyName)
Form {
// Name section
Section {
TextField(String.localized("First name"), text: $firstName)
.textContentType(.givenName)
TextField(String.localized("Last name"), text: $lastName)
.textContentType(.familyName)
}
// Professional section (moved before contact)
Section {
TextField(String.localized("Job title"), text: $jobTitle)
.textContentType(.jobTitle)
TextField(String.localized("Company"), text: $company)
.textContentType(.organizationName)
}
// 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)
}
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
// Contact section
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
EditorTextField(placeholder: String.localized("Phone"), text: $phone)
.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)
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)
}
.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)
EditorTextField(placeholder: String.localized("Company"), text: $company)
.textContentType(.organizationName)
Button {
emailEntries.append(LabeledEntry(label: "Work", value: ""))
} label: {
Label(String.localized("Add email"), systemImage: "plus.circle.fill")
}
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
} 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"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -88,27 +151,83 @@ struct AddContactSheet: View {
}
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(
name: fullName,
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines)
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
contactFields: contactFields
)
dismiss()
}
}
// MARK: - Shared Editor TextField
// MARK: - Labeled Entry Model
private struct EditorTextField: View {
let placeholder: String
@Binding var text: String
private struct LabeledEntry: Identifiable {
let id = UUID()
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 {
VStack(alignment: .leading, spacing: 0) {
TextField(placeholder, text: $text)
Divider()
HStack(spacing: Design.Spacing.medium) {
// Label picker
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
@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 {
let contact = 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 **plus icon** to create a new card
- Set a default card for sharing
- **Modern card design**: Banner with company logo, overlapping profile photo, clean contact rows
- **Profile photos**: Add a photo from your library or use an icon
- **Company logos**: Upload a logo to display on your card's banner
- **Modern card design**: Banner with optional cover photo, company logo, overlapping profile photo, clean contact rows
- **Profile photos**: Add a headshot from your library or use an icon
- **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
- **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)
- **Dynamic contact fields**: `@Relationship` to array of `ContactField` objects
- Legacy fields: social links (LinkedIn, Twitter, etc.) for backward compatibility
- Photo: `photoData` and `logoData` stored with `@Attribute(.externalStorage)`
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks`, `orderedContactFields`
- Photos: `photoData` (profile), `coverPhotoData` (banner background), `logoData` (company logo) stored with `@Attribute(.externalStorage)`
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `orderedContactFields`
- Helper methods: `addContactField`, `removeContactField`, `reorderContactFields`
- `Models/ContactField.swift` — SwiftData model for dynamic contact fields:
@ -112,7 +112,7 @@ Main screens:
Feature views:
- `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/QRScannerView.swift` — camera-based QR scanner
- `Views/QRCodeView.swift` — QR code image generator