Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
6ee74c422b
commit
6d9c956c06
@ -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: " ")
|
||||
}
|
||||
@ -322,12 +206,6 @@ final class BusinessCard {
|
||||
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 = [
|
||||
"BEGIN:VCARD",
|
||||
@ -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(
|
||||
// Sample 1: Property Developer
|
||||
let sample1 = 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(
|
||||
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",
|
||||
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(
|
||||
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",
|
||||
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"
|
||||
bio: "Bringing the beats to your events"
|
||||
)
|
||||
]
|
||||
|
||||
for sample in samples {
|
||||
context.insert(sample)
|
||||
}
|
||||
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: "")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -125,6 +125,7 @@ extension ContactFieldType {
|
||||
Dictionary(grouping: allCases, by: { $0.category })
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Contact
|
||||
|
||||
static let phone = ContactFieldType(
|
||||
|
||||
@ -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" : {
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -37,20 +37,30 @@ private struct CardBannerView: View {
|
||||
|
||||
var body: some View {
|
||||
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(
|
||||
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
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
Form {
|
||||
// Name section
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
EditorTextField(placeholder: String.localized("First name"), text: $firstName)
|
||||
Section {
|
||||
TextField(String.localized("First name"), text: $firstName)
|
||||
.textContentType(.givenName)
|
||||
EditorTextField(placeholder: String.localized("Last name"), text: $lastName)
|
||||
TextField(String.localized("Last name"), text: $lastName)
|
||||
.textContentType(.familyName)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.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)
|
||||
// Professional section (moved before contact)
|
||||
Section {
|
||||
TextField(String.localized("Job title"), text: $jobTitle)
|
||||
.textContentType(.jobTitle)
|
||||
EditorTextField(placeholder: String.localized("Company"), text: $company)
|
||||
TextField(String.localized("Company"), text: $company)
|
||||
.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"))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user