From 6d9c956c06e71219bfafe83c61830c964bc07e24 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 9 Jan 2026 08:10:12 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard/Models/BusinessCard.swift | 346 +---- BusinessCard/Models/Contact.swift | 76 +- BusinessCard/Models/ContactField.swift | 3 + BusinessCard/Models/ContactFieldType.swift | 1 + BusinessCard/Resources/Localizable.xcstrings | 55 +- BusinessCard/Services/WatchSyncService.swift | 26 +- BusinessCard/State/ContactsStore.swift | 17 +- BusinessCard/Views/BusinessCardView.swift | 186 +-- BusinessCard/Views/CardEditorView.swift | 1334 +++++------------ BusinessCard/Views/ContactDetailView.swift | 97 +- .../Views/Sheets/AddContactSheet.swift | 211 ++- BusinessCardTests/BusinessCardTests.swift | 8 - README.md | 7 +- ai_implmentation.md | 6 +- 14 files changed, 847 insertions(+), 1526 deletions(-) diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift index f455ce2..3886ea8 100644 --- a/BusinessCard/Models/BusinessCard.swift +++ b/BusinessCard/Models/BusinessCard.swift @@ -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 = ["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: "") } } diff --git a/BusinessCard/Models/Contact.swift b/BusinessCard/Models/Contact.swift index 39adc55..df4ffc0 100644 --- a/BusinessCard/Models/Contact.swift +++ b/BusinessCard/Models/Contact.swift @@ -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") diff --git a/BusinessCard/Models/ContactField.swift b/BusinessCard/Models/ContactField.swift index 1abe272..388d48f 100644 --- a/BusinessCard/Models/ContactField.swift +++ b/BusinessCard/Models/ContactField.swift @@ -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 diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift index cb98b59..c94d83b 100644 --- a/BusinessCard/Models/ContactFieldType.swift +++ b/BusinessCard/Models/ContactFieldType.swift @@ -125,6 +125,7 @@ extension ContactFieldType { Dictionary(grouping: allCases, by: { $0.category }) } + // MARK: - Contact static let phone = ContactFieldType( diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index cc510f8..53408e3 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -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" : { diff --git a/BusinessCard/Services/WatchSyncService.swift b/BusinessCard/Services/WatchSyncService.swift index 3880772..8d71205 100644 --- a/BusinessCard/Services/WatchSyncService.swift +++ b/BusinessCard/Services/WatchSyncService.swift @@ -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 ) } diff --git a/BusinessCard/State/ContactsStore.swift b/BusinessCard/State/ContactsStore.swift index d450594..c531a74 100644 --- a/BusinessCard/State/ContactsStore.swift +++ b/BusinessCard/State/ContactsStore.swift @@ -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() } diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift index 6cb4d4d..3ab4d98 100644 --- a/BusinessCard/Views/BusinessCardView.swift +++ b/BusinessCard/Views/BusinessCardView.swift @@ -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) diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index 2984f19..0b74f82 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -31,33 +31,6 @@ struct CardEditorView: View { @State private var bio = "" @State private var accreditations = "" - // Contact details - @State private var email = "" - @State private var emailLabel = "Work" - @State private var phone = "" - @State private var phoneLabel = "Cell" - @State private var phoneExtension = "" - @State private var website = "" - @State private var location = "" - - // Social media - @State private var linkedIn = "" - @State private var twitter = "" - @State private var instagram = "" - @State private var facebook = "" - @State private var tiktok = "" - @State private var github = "" - @State private var threads = "" - @State private var telegram = "" - @State private var venmo = "" - @State private var cashApp = "" - - // Custom links - @State private var customLink1Title = "" - @State private var customLink1URL = "" - @State private var customLink2Title = "" - @State private var customLink2URL = "" - // Contact fields (unified list for picker-based UI) @State private var contactFields: [AddedContactField] = [] @@ -69,6 +42,8 @@ struct CardEditorView: View { // Photos @State private var selectedPhoto: PhotosPickerItem? @State private var photoData: Data? + @State private var selectedCoverPhoto: PhotosPickerItem? + @State private var coverPhotoData: Data? @State private var selectedLogo: PhotosPickerItem? @State private var logoData: Data? @@ -106,54 +81,100 @@ struct CardEditorView: View { var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 0) { - // Card Style section - CardStyleSection(selectedTheme: $selectedTheme) - - // Images & Layout section - ImagesLayoutSection( + Form { + // Card Style section + Section { + CardStylePicker(selectedTheme: $selectedTheme) + } + + // Images & Layout section + Section { + ImageLayoutRow( selectedPhoto: $selectedPhoto, photoData: $photoData, + selectedCoverPhoto: $selectedCoverPhoto, + coverPhotoData: $coverPhotoData, selectedLogo: $selectedLogo, logoData: $logoData, avatarSystemName: avatarSystemName, selectedTheme: selectedTheme ) - - // Personal details section - PersonalDetailsSection( - displayName: $displayName, - prefix: $prefix, - firstName: $firstName, - middleName: $middleName, - lastName: $lastName, - suffix: $suffix, - maidenName: $maidenName, - preferredName: $preferredName, - pronouns: $pronouns, - showNameDetails: $showNameDetails - ) - - // Professional section - ProfessionalSection( - role: $role, - department: $department, - company: $company, - headline: $headline, - accreditations: $accreditations, - label: $label - ) - - // Contact & social fields manager - ContactFieldsManagerView(fields: $contactFields) - - // Bio section - BioSection(bio: $bio) + } header: { + Text("Images & layout") + } + + // Personal details section + Section { + // Name row with expand button + Button { + withAnimation { showNameDetails.toggle() } + } label: { + HStack { + Text(formattedDisplayName.isEmpty ? "Full Name" : formattedDisplayName) + .foregroundStyle(formattedDisplayName.isEmpty ? Color.secondary : Color.primary) + Spacer() + Image(systemName: showNameDetails ? "chevron.up" : "chevron.down") + .foregroundStyle(Color.accentColor) + } + } + .tint(.primary) + + if showNameDetails { + TextField("Prefix (e.g. Dr., Mr., Ms.)", text: $prefix) + TextField("First Name", text: $firstName) + TextField("Middle Name", text: $middleName) + TextField("Last Name", text: $lastName) + TextField("Suffix (e.g. Jr., III)", text: $suffix) + TextField("Maiden Name", text: $maidenName) + TextField("Preferred Name", text: $preferredName) + TextField("Pronouns (e.g. she/her)", text: $pronouns) + } + } header: { + Text("Personal details") + } + + // Professional section + Section { + TextField("Job Title", text: $role) + TextField("Department", text: $department) + TextField("Company", text: $company) + TextField("Headline", text: $headline) + } + + // Accreditations + Section { + AccreditationsRow(accreditations: $accreditations) + } header: { + Text("Accreditations") + } + + // Card Label + Section { + Picker("Card Label", selection: $label) { + ForEach(["Work", "Personal", "Creative", "Other"], id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(.segmented) + } header: { + Text("Card Label") + } + + // Contact & social fields manager + Section { + ContactFieldsManagerView(fields: $contactFields) + } header: { + Text("Contact Fields") + } + + // Bio section + Section { + TextField("Tell people about yourself...", text: $bio, axis: .vertical) + .lineLimit(3...8) + } header: { + Text("About") } - .padding(.bottom, Design.Spacing.xxLarge * 2) } - .background(Color.AppBackground.base) .safeAreaInset(edge: .bottom) { PreviewCardButton { showingPreview = true } } @@ -176,6 +197,13 @@ struct CardEditorView: View { } } } + .onChange(of: selectedCoverPhoto) { _, newValue in + Task { + if let data = try? await newValue?.loadTransferable(type: Data.self) { + coverPhotoData = data + } + } + } .onChange(of: selectedLogo) { _, newValue in Task { if let data = try? await newValue?.loadTransferable(type: Data.self) { @@ -191,78 +219,43 @@ struct CardEditorView: View { } } -// MARK: - Card Style Section +// MARK: - Card Style Picker -private struct CardStyleSection: View { +private struct CardStylePicker: View { @Binding var selectedTheme: CardTheme var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - Text("Card style") - .font(.headline) - .foregroundStyle(Color.Text.primary) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: Design.Spacing.medium) { - // Rainbow/custom option - ColorSwatchButton( - isSelected: false, - gradient: LinearGradient( - colors: [.red, .orange, .yellow, .green, .blue, .purple], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) { } - - ForEach(CardTheme.all) { theme in - ColorSwatchButton( - isSelected: selectedTheme == theme, - color: theme.primaryColor - ) { - selectedTheme = theme - } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Design.Spacing.medium) { + ForEach(CardTheme.all) { theme in + Button { + selectedTheme = theme + } label: { + Circle() + .fill(theme.primaryColor) + .frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize) + .overlay( + Circle() + .stroke(selectedTheme == theme ? Color.primary : .clear, lineWidth: Design.LineWidth.medium) + .padding(Design.Spacing.xxSmall) + ) } + .buttonStyle(.plain) + .accessibilityLabel(theme.name) + .accessibilityAddTraits(selectedTheme == theme ? .isSelected : []) } - .padding(.horizontal, Design.Spacing.xxSmall) } } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) } } -private struct ColorSwatchButton: View { - let isSelected: Bool - var color: Color? = nil - var gradient: LinearGradient? = nil - let action: () -> Void - - var body: some View { - Button(action: action) { - Group { - if let gradient { - Circle().fill(gradient) - } else if let color { - Circle().fill(color) - } - } - .frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize) - .overlay( - Circle() - .stroke(isSelected ? Color.Text.primary : .clear, lineWidth: Design.LineWidth.medium) - .padding(Design.Spacing.xxSmall) - ) - } - .buttonStyle(.plain) - .accessibilityAddTraits(isSelected ? .isSelected : []) - } -} +// MARK: - Image Layout Row -// MARK: - Images & Layout Section - -private struct ImagesLayoutSection: View { +private struct ImageLayoutRow: View { @Binding var selectedPhoto: PhotosPickerItem? @Binding var photoData: Data? + @Binding var selectedCoverPhoto: PhotosPickerItem? + @Binding var coverPhotoData: Data? @Binding var selectedLogo: PhotosPickerItem? @Binding var logoData: Data? let avatarSystemName: String @@ -270,49 +263,16 @@ private struct ImagesLayoutSection: View { var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.medium) { - Text("Images & layout") - .font(.headline) - .foregroundStyle(Color.Text.primary) - // Card preview with edit buttons ZStack(alignment: .bottomLeading) { - // Banner - ZStack { - LinearGradient( - colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - - // Logo - ZStack { - if let logoData, let uiImage = UIImage(data: logoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - .frame(height: Design.CardSize.logoSize) - } - - // Edit logo button - VStack { - HStack { - Spacer() - PhotosPicker(selection: $selectedLogo, matching: .images) { - Image(systemName: "pencil") - .font(.caption) - .padding(Design.Spacing.small) - .background(.ultraThinMaterial) - .clipShape(.circle) - } - .buttonStyle(.plain) - } - Spacer() - } - .padding(Design.Spacing.small) - } - } - .frame(height: Design.CardSize.bannerHeight) - .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + // Banner with cover photo or gradient + BannerPreviewView( + coverPhotoData: coverPhotoData, + logoData: logoData, + selectedTheme: selectedTheme, + selectedCoverPhoto: $selectedCoverPhoto, + selectedLogo: $selectedLogo + ) // Profile photo with edit button ZStack(alignment: .bottomTrailing) { @@ -331,18 +291,182 @@ private struct ImagesLayoutSection: View { } .padding(.bottom, Design.CardSize.avatarOverlap) - // Add cover photo button - if logoData == nil { - PhotosPicker(selection: $selectedLogo, matching: .images) { - Label("Cover photo", systemImage: "plus") - .font(.subheadline) - .foregroundStyle(Color.Accent.red) + // Photo action buttons + ImageActionButtonsRow( + photoData: $photoData, + coverPhotoData: $coverPhotoData, + logoData: $logoData, + selectedPhoto: $selectedPhoto, + selectedCoverPhoto: $selectedCoverPhoto, + selectedLogo: $selectedLogo + ) + } + } +} + +// MARK: - Banner Preview View + +private struct BannerPreviewView: View { + let coverPhotoData: Data? + let logoData: Data? + let selectedTheme: CardTheme + @Binding var selectedCoverPhoto: PhotosPickerItem? + @Binding var selectedLogo: PhotosPickerItem? + + var body: some View { + ZStack { + // Background: cover photo or gradient + if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(height: Design.CardSize.bannerHeight) + .clipped() + } else { + LinearGradient( + colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // Company logo overlay + if let logoData, let uiImage = UIImage(data: logoData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(height: Design.CardSize.logoSize) + } + + // Edit buttons overlay + VStack { + HStack { + // Edit cover photo button (top-left) + PhotosPicker(selection: $selectedCoverPhoto, matching: .images) { + Image(systemName: "photo") + .font(.caption) + .padding(Design.Spacing.small) + .background(.ultraThinMaterial) + .clipShape(.circle) + } + .buttonStyle(.plain) + .accessibilityLabel(String.localized("Edit cover photo")) + + Spacer() + + // Edit logo button (top-right) + PhotosPicker(selection: $selectedLogo, matching: .images) { + Image(systemName: "building.2") + .font(.caption) + .padding(Design.Spacing.small) + .background(.ultraThinMaterial) + .clipShape(.circle) + } + .buttonStyle(.plain) + .accessibilityLabel(String.localized("Edit company logo")) + } + Spacer() + } + .padding(Design.Spacing.small) + } + .frame(height: Design.CardSize.bannerHeight) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + } +} + +// MARK: - Image Action Buttons Row + +private struct ImageActionButtonsRow: View { + @Binding var photoData: Data? + @Binding var coverPhotoData: Data? + @Binding var logoData: Data? + @Binding var selectedPhoto: PhotosPickerItem? + @Binding var selectedCoverPhoto: PhotosPickerItem? + @Binding var selectedLogo: PhotosPickerItem? + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + // Profile photo action + ImageActionRow( + title: String.localized("Profile Photo"), + subtitle: photoData == nil ? String.localized("Add your headshot") : String.localized("Change or remove"), + systemImage: "person.crop.circle", + hasImage: photoData != nil, + selection: $selectedPhoto, + onRemove: { photoData = nil } + ) + + // Cover photo action + ImageActionRow( + title: String.localized("Cover Photo"), + subtitle: coverPhotoData == nil ? String.localized("Add banner background") : String.localized("Change or remove"), + systemImage: "photo.fill", + hasImage: coverPhotoData != nil, + selection: $selectedCoverPhoto, + onRemove: { coverPhotoData = nil } + ) + + // Company logo action + ImageActionRow( + title: String.localized("Company Logo"), + subtitle: logoData == nil ? String.localized("Add your logo") : String.localized("Change or remove"), + systemImage: "building.2", + hasImage: logoData != nil, + selection: $selectedLogo, + onRemove: { logoData = nil } + ) + } + } +} + +// MARK: - Image Action Row + +private struct ImageActionRow: View { + let title: String + let subtitle: String + let systemImage: String + let hasImage: Bool + @Binding var selection: PhotosPickerItem? + let onRemove: () -> Void + + var body: some View { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(hasImage ? Color.accentColor : Color.Text.secondary) + .frame(width: Design.CardSize.socialIconSize) + + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(title) + .font(.subheadline) + .foregroundStyle(Color.Text.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(Color.Text.secondary) + } + + Spacer() + + if hasImage { + Button { + onRemove() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.Text.tertiary) } .buttonStyle(.plain) + .accessibilityLabel(String.localized("Remove \(title.lowercased())")) } + + PhotosPicker(selection: $selection, matching: .images) { + Image(systemName: hasImage ? "arrow.triangle.2.circlepath" : "plus.circle.fill") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .accessibilityLabel(hasImage ? String.localized("Change \(title.lowercased())") : String.localized("Add \(title.lowercased())")) } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) + .padding(.vertical, Design.Spacing.xSmall) } } @@ -371,126 +495,11 @@ private struct ProfilePhotoView: View { } } -// MARK: - Personal Details Section +// MARK: - Accreditations Row -private struct PersonalDetailsSection: View { - @Binding var displayName: String - @Binding var prefix: String - @Binding var firstName: String - @Binding var middleName: String - @Binding var lastName: String - @Binding var suffix: String - @Binding var maidenName: String - @Binding var preferredName: String - @Binding var pronouns: String - @Binding var showNameDetails: Bool - - /// Computed display name with special formatting: - /// - Preferred name in quotes: "Bubba" - /// - Maiden name in parentheses: (Hackney) - /// - Pronouns in parentheses: (He/Him) - private var computedName: String { - if !displayName.isEmpty { return displayName } - - 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))") - } - - return parts.joined(separator: " ") - } - - var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - Text("Personal details") - .font(.headline) - .foregroundStyle(Color.Text.primary) - - // Name row with expand button - Button { - withAnimation { showNameDetails.toggle() } - } label: { - HStack { - Text(computedName.isEmpty ? "Full Name" : computedName) - .foregroundStyle(computedName.isEmpty ? Color.Text.secondary : Color.Text.primary) - Spacer() - Image(systemName: showNameDetails ? "chevron.up" : "chevron.down") - .foregroundStyle(Color.Accent.red) - } - } - .buttonStyle(.plain) - - Divider() - .overlay(Color.Accent.red) - - if showNameDetails { - VStack(spacing: Design.Spacing.small) { - EditorTextField(placeholder: "Prefix (e.g. Dr., Mr., Ms.)", text: $prefix) - EditorTextField(placeholder: "First Name", text: $firstName) - EditorTextField(placeholder: "Middle Name", text: $middleName) - EditorTextField(placeholder: "Last Name", text: $lastName) - EditorTextField(placeholder: "Suffix (e.g. Jr., III)", text: $suffix) - EditorTextField(placeholder: "Maiden Name", text: $maidenName) - EditorTextField(placeholder: "Preferred Name", text: $preferredName) - EditorTextField(placeholder: "Pronouns (e.g. she/her, he/him)", text: $pronouns) - } - .padding(.leading, Design.Spacing.large) - } - } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) - } -} - -// MARK: - Professional Section - -private struct ProfessionalSection: View { - @Binding var role: String - @Binding var department: String - @Binding var company: String - @Binding var headline: String +private struct AccreditationsRow: View { @Binding var accreditations: String - @Binding var label: String - @State private var accreditationInput = "" - @State private var editingIndex: Int? = nil private var accreditationsList: [String] { accreditations.split(separator: ",") @@ -499,88 +508,48 @@ private struct ProfessionalSection: View { } var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - EditorTextField(placeholder: "Job Title", text: $role) - EditorTextField(placeholder: "Department", text: $department) - EditorTextField(placeholder: "Company", text: $company) - EditorTextField(placeholder: "Headline", text: $headline) - - // Accreditations section - VStack(alignment: .leading, spacing: Design.Spacing.small) { - Text("Accreditations") - .font(.caption) - .foregroundStyle(Color.Text.secondary) + VStack(alignment: .leading, spacing: Design.Spacing.small) { + // Input row + HStack { + TextField("e.g. MBA, CPA, PhD", text: $accreditationInput) - // Input row - HStack(spacing: Design.Spacing.small) { - TextField("e.g. MBA, CPA, PhD", text: $accreditationInput) - .textFieldStyle(.roundedBorder) - - if editingIndex != nil { - // Editing mode - show check and delete - Button { - saveEdit() - } label: { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.Accent.mint) - } - .buttonStyle(.plain) - .disabled(accreditationInput.trimmingCharacters(in: .whitespaces).isEmpty) - - Button { - deleteEditing() - } label: { - Image(systemName: "trash.circle.fill") - .foregroundStyle(Color.Accent.red) - } - .buttonStyle(.plain) - } else { - // Add mode - Button { - addAccreditation() - } label: { - Image(systemName: "plus.circle.fill") - .foregroundStyle(Color.Accent.red) - } - .buttonStyle(.plain) - .disabled(accreditationInput.trimmingCharacters(in: .whitespaces).isEmpty) - } - } - - // Tag bubbles - if !accreditationsList.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: Design.Spacing.small) { - ForEach(accreditationsList.indices, id: \.self) { index in - AccreditationTagView( - text: accreditationsList[index], - isEditing: editingIndex == index - ) { - startEditing(index: index) - } - } - } - } + Button { + addAccreditation() + } label: { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(Color.accentColor) } + .buttonStyle(.plain) + .disabled(accreditationInput.trimmingCharacters(in: .whitespaces).isEmpty) } - // Card label picker - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - Text("Card Label") - .font(.caption) - .foregroundStyle(Color.Text.secondary) - - HStack(spacing: Design.Spacing.small) { - ForEach(["Work", "Personal", "Creative", "Other"], id: \.self) { option in - LabelChip(title: option, isSelected: label == option) { - label = option + // Tag bubbles + if !accreditationsList.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Design.Spacing.small) { + ForEach(accreditationsList, id: \.self) { tag in + HStack(spacing: Design.Spacing.xSmall) { + Text(tag) + .font(.subheadline) + Button { + removeAccreditation(tag) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(Color.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xSmall) + .background(Color.secondary.opacity(Design.Opacity.subtle)) + .clipShape(.capsule) } } } } } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) } private func addAccreditation() { @@ -593,377 +562,15 @@ private struct ProfessionalSection: View { accreditationInput = "" } - private func startEditing(index: Int) { - editingIndex = index - accreditationInput = accreditationsList[index] - } - - private func saveEdit() { - guard let index = editingIndex else { return } - let trimmed = accreditationInput.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return } - + private func removeAccreditation(_ tag: String) { var list = accreditationsList - if index < list.count { - list[index] = trimmed - accreditations = list.joined(separator: ", ") - } - - editingIndex = nil - accreditationInput = "" - } - - private func deleteEditing() { - guard let index = editingIndex else { return } - - var list = accreditationsList - if index < list.count { - list.remove(at: index) - accreditations = list.joined(separator: ", ") - } - - editingIndex = nil - accreditationInput = "" - } -} - -private struct AccreditationTagView: View { - let text: String - let isEditing: Bool - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - Text(text) - .font(.subheadline) - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.xSmall) - .background(isEditing ? Color.Accent.red : Color.AppBackground.accent) - .foregroundStyle(isEditing ? .white : Color.Text.primary) - .clipShape(.capsule) - .overlay( - Capsule() - .stroke(isEditing ? Color.Accent.red : .clear, lineWidth: Design.LineWidth.thin) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(text) - .accessibilityHint("Tap to edit this accreditation") - } -} - -private struct LabelChip: View { - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(.caption) - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.xSmall) - .background(isSelected ? Color.Accent.red : Color.AppBackground.accent) - .foregroundStyle(isSelected ? .white : Color.Text.primary) - .clipShape(.capsule) - } - .buttonStyle(.plain) - } -} - -// MARK: - Contact Fields Section - -private struct ContactFieldsSection: View { - @Binding var email: String - @Binding var emailLabel: String - @Binding var phone: String - @Binding var phoneLabel: String - @Binding var phoneExtension: String - @Binding var website: String - @Binding var location: String - @Binding var bio: String - - var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - Text("Hold each field below to re-order it") - .font(.caption) - .foregroundStyle(Color.Text.secondary) - .frame(maxWidth: .infinity) - .padding(Design.Spacing.medium) - .background(Color.AppBackground.accent) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) - - // Email field - ContactFieldRow( - systemImage: "envelope.fill", - content: { - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - TextField("Email", text: $email) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - - Divider() - - TextField("Label", text: $emailLabel) - .font(.caption) - .foregroundStyle(Color.Text.secondary) - } - } - ) - - // Phone field - ContactFieldRow( - systemImage: "phone.fill", - content: { - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - HStack { - TextField("Phone", text: $phone) - .textContentType(.telephoneNumber) - .keyboardType(.phonePad) - - TextField("Ext.", text: $phoneExtension) - .frame(width: Design.CardSize.avatarSize) - .keyboardType(.phonePad) - } - - Divider() - - TextField("Label", text: $phoneLabel) - .font(.caption) - .foregroundStyle(Color.Text.secondary) - } - } - ) - - // Website field - ContactFieldRow( - systemImage: "globe", - content: { - TextField("Website", text: $website) - .textContentType(.URL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - } - ) - - // Location field - ContactFieldRow( - systemImage: "location.fill", - content: { - TextField("Location", text: $location) - .textContentType(.fullStreetAddress) - } - ) - - // Bio field - ContactFieldRow( - systemImage: "text.alignleft", - content: { - TextField("Bio", text: $bio, axis: .vertical) - .lineLimit(3...6) - } - ) - } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) - } -} - -private struct ContactFieldRow: View { - let systemImage: String - @ViewBuilder let content: Content - - var body: some View { - HStack(alignment: .top, spacing: Design.Spacing.medium) { - Image(systemName: systemImage) - .font(.body) - .foregroundStyle(Color.Text.secondary) - .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize) - .background(Color.AppBackground.accent) - .clipShape(.circle) - - VStack(alignment: .leading) { - content - Divider() - } - - Button(role: .destructive) { } label: { - Image(systemName: "xmark") - .font(.caption) - .foregroundStyle(Color.Text.secondary) - } - .buttonStyle(.plain) - } - } -} - -// MARK: - Social Links Section - -private struct SocialLinksSection: View { - @Binding var linkedIn: String - @Binding var twitter: String - @Binding var instagram: String - @Binding var facebook: String - @Binding var tiktok: String - @Binding var github: String - @Binding var threads: String - @Binding var telegram: String - - var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - Text("Social Media") - .font(.headline) - .foregroundStyle(Color.Text.primary) - - SocialLinkRow(platform: "LinkedIn", placeholder: "linkedin.com/in/username", color: Color.Social.linkedIn, text: $linkedIn, suggestion: "Connect with me on LinkedIn") - SocialLinkRow(platform: "X / Twitter", placeholder: "x.com/username", color: Color.Social.twitter, text: $twitter, suggestion: "Follow me on X") - SocialLinkRow(platform: "Instagram", placeholder: "instagram.com/username", color: Color.Social.instagram, text: $instagram, suggestion: "Follow me on Instagram") - SocialLinkRow(platform: "Facebook", placeholder: "facebook.com/username", color: Color.Social.facebook, text: $facebook, suggestion: nil) - SocialLinkRow(platform: "TikTok", placeholder: "tiktok.com/@username", color: Color.Social.tiktok, text: $tiktok, suggestion: "Follow me on TikTok") - SocialLinkRow(platform: "GitHub", placeholder: "github.com/username", color: Color.Social.github, text: $github, suggestion: "View our work on GitHub") - SocialLinkRow(platform: "Threads", placeholder: "threads.net/@username", color: Color.Social.threads, text: $threads, suggestion: "Follow me on Threads") - SocialLinkRow(platform: "Telegram", placeholder: "t.me/username", color: Color.Social.telegram, text: $telegram, suggestion: "Connect with me on Telegram") - } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) - } -} - -private struct SocialLinkRow: View { - let platform: String - let placeholder: String - let color: Color - @Binding var text: String - let suggestion: String? - - var body: some View { - HStack(spacing: Design.Spacing.medium) { - Circle() - .fill(color) - .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize) - .overlay( - Text(String(platform.prefix(2))) - .font(.caption2) - .bold() - .foregroundStyle(.white) - ) - - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - TextField(platform, text: $text, prompt: Text(placeholder).foregroundStyle(Color.Text.secondary)) - .textContentType(.URL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - - if let suggestion, text.isEmpty { - Button { - // Could pre-fill with suggestion - } label: { - Text(suggestion) - .font(.caption2) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xxSmall) - .background(Color.AppBackground.accent) - .clipShape(.capsule) - } - .buttonStyle(.plain) - } - } - } - } -} - -// MARK: - Payment Links Section - -private struct PaymentLinksSection: View { - @Binding var venmo: String - @Binding var cashApp: String - - var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - Text("Payment") - .font(.headline) - .foregroundStyle(Color.Text.primary) - - SocialLinkRow(platform: "Venmo", placeholder: "@username", color: Color.Social.venmo, text: $venmo, suggestion: "Pay via Venmo") - SocialLinkRow(platform: "Cash App", placeholder: "$username", color: Color.Social.cashApp, text: $cashApp, suggestion: "Pay via Cash App") - } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) - } -} - -// MARK: - Bio Section - -private struct BioSection: View { - @Binding var bio: String - - var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.small) { - Text("About") - .font(.subheadline.bold()) - .foregroundStyle(Color.Text.primary) - - TextField("Tell people about yourself...", text: $bio, axis: .vertical) - .lineLimit(3...8) - .textFieldStyle(.plain) - - Divider() - } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) - } -} - -// MARK: - Custom Links Section - -private struct CustomLinksSection: View { - @Binding var customLink1Title: String - @Binding var customLink1URL: String - @Binding var customLink2Title: String - @Binding var customLink2URL: String - - var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - Text("Custom Links") - .font(.headline) - .foregroundStyle(Color.Text.primary) - - VStack(alignment: .leading, spacing: Design.Spacing.small) { - EditorTextField(placeholder: "Link 1 Title", text: $customLink1Title) - EditorTextField(placeholder: "Link 1 URL", text: $customLink1URL) - .textContentType(.URL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - } - - VStack(alignment: .leading, spacing: Design.Spacing.small) { - EditorTextField(placeholder: "Link 2 Title", text: $customLink2Title) - EditorTextField(placeholder: "Link 2 URL", text: $customLink2URL) - .textContentType(.URL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - } - } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) + list.removeAll { $0 == tag } + accreditations = list.joined(separator: ", ") } } // MARK: - Supporting Views -private struct EditorTextField: View { - let placeholder: String - @Binding var text: String - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - TextField(placeholder, text: $text) - Divider() - } - } -} - private struct PreviewCardButton: View { let action: () -> Void @@ -1026,31 +633,10 @@ private extension CardEditorView { label = card.label bio = card.bio accreditations = card.accreditations - email = card.email - emailLabel = card.emailLabel - phone = card.phone - phoneLabel = card.phoneLabel - phoneExtension = card.phoneExtension - website = card.website - location = card.location - linkedIn = card.linkedIn - twitter = card.twitter - instagram = card.instagram - facebook = card.facebook - tiktok = card.tiktok - github = card.github - threads = card.threads - telegram = card.telegram - venmo = card.venmo - cashApp = card.cashApp - customLink1Title = card.customLink1Title - customLink1URL = card.customLink1URL - customLink2Title = card.customLink2Title - customLink2URL = card.customLink2URL avatarSystemName = card.avatarSystemName - // Load contact fields into the array - contactFields = buildContactFieldsArray(from: card) + // Load contact fields from the array + contactFields = card.orderedContactFields.compactMap { $0.toAddedContactField() } selectedTheme = card.theme selectedLayout = card.layoutStyle photoData = card.photoData @@ -1058,9 +644,6 @@ private extension CardEditorView { } func saveCard() { - // Sync contact fields to individual properties before saving - syncContactFieldsToProperties() - if let existingCard = card { updateCard(existingCard) onSave(existingCard) @@ -1088,27 +671,6 @@ private extension CardEditorView { card.label = label card.bio = bio card.accreditations = accreditations - card.email = email - card.emailLabel = emailLabel - card.phone = phone - card.phoneLabel = phoneLabel - card.phoneExtension = phoneExtension - card.website = website - card.location = location - card.linkedIn = linkedIn - card.twitter = twitter - card.instagram = instagram - card.facebook = facebook - card.tiktok = tiktok - card.github = github - card.threads = threads - card.telegram = telegram - card.venmo = venmo - card.cashApp = cashApp - card.customLink1Title = customLink1Title - card.customLink1URL = customLink1URL - card.customLink2Title = customLink2Title - card.customLink2URL = customLink2URL card.avatarSystemName = avatarSystemName card.theme = selectedTheme card.layoutStyle = selectedLayout @@ -1122,20 +684,27 @@ private extension CardEditorView { func createCard() -> BusinessCard { let newCard = BusinessCard( displayName: displayName.isEmpty ? effectiveDisplayName : displayName, - role: role, company: company, label: label, - email: email, emailLabel: emailLabel, - phone: phone, phoneLabel: phoneLabel, phoneExtension: phoneExtension, - website: website, location: location, - isDefault: false, themeName: selectedTheme.name, layoutStyleRawValue: selectedLayout.rawValue, + role: role, + company: company, + label: label, + isDefault: false, + themeName: selectedTheme.name, + layoutStyleRawValue: selectedLayout.rawValue, avatarSystemName: avatarSystemName, - prefix: prefix, firstName: firstName, middleName: middleName, lastName: lastName, - suffix: suffix, maidenName: maidenName, preferredName: preferredName, - pronouns: pronouns, department: department, headline: headline, bio: bio, accreditations: accreditations, - linkedIn: linkedIn, twitter: twitter, instagram: instagram, facebook: facebook, - tiktok: tiktok, github: github, threads: threads, telegram: telegram, venmo: venmo, cashApp: cashApp, - customLink1Title: customLink1Title, customLink1URL: customLink1URL, - customLink2Title: customLink2Title, customLink2URL: customLink2URL, - photoData: photoData, logoData: logoData + prefix: prefix, + firstName: firstName, + middleName: middleName, + lastName: lastName, + suffix: suffix, + maidenName: maidenName, + preferredName: preferredName, + pronouns: pronouns, + department: department, + headline: headline, + bio: bio, + accreditations: accreditations, + photoData: photoData, + logoData: logoData ) // Save contact fields to the model's array @@ -1145,94 +714,50 @@ private extension CardEditorView { } func buildPreviewCard() -> BusinessCard { - // Sync contact fields before building preview - syncContactFieldsToProperties() - - return BusinessCard( + let previewCard = BusinessCard( displayName: displayName.isEmpty ? effectiveDisplayName : displayName, - role: role, company: company, label: label, - email: email, emailLabel: emailLabel, - phone: phone, phoneLabel: phoneLabel, phoneExtension: phoneExtension, - website: website, location: location, - isDefault: false, themeName: selectedTheme.name, layoutStyleRawValue: selectedLayout.rawValue, + role: role, + company: company, + label: label, + isDefault: false, + themeName: selectedTheme.name, + layoutStyleRawValue: selectedLayout.rawValue, avatarSystemName: avatarSystemName, - prefix: prefix, firstName: firstName, middleName: middleName, lastName: lastName, - suffix: suffix, maidenName: maidenName, preferredName: preferredName, - pronouns: pronouns, department: department, headline: headline, bio: bio, accreditations: accreditations, - linkedIn: linkedIn, twitter: twitter, instagram: instagram, facebook: facebook, - tiktok: tiktok, github: github, threads: threads, telegram: telegram, venmo: venmo, cashApp: cashApp, - customLink1Title: customLink1Title, customLink1URL: customLink1URL, - customLink2Title: customLink2Title, customLink2URL: customLink2URL, - photoData: photoData, logoData: logoData + prefix: prefix, + firstName: firstName, + middleName: middleName, + lastName: lastName, + suffix: suffix, + maidenName: maidenName, + preferredName: preferredName, + pronouns: pronouns, + department: department, + headline: headline, + bio: bio, + accreditations: accreditations, + photoData: photoData, + logoData: logoData ) + + // Add contact fields to preview card + for (index, field) in contactFields.enumerated() { + let contactField = ContactField( + typeId: field.fieldType.id, + value: field.value, + title: field.title, + orderIndex: index + ) + if previewCard.contactFields == nil { + previewCard.contactFields = [] + } + previewCard.contactFields?.append(contactField) + } + + return previewCard } // MARK: - Contact Fields Sync - /// Builds an array of AddedContactField from a BusinessCard - /// Prefers the new contactFields array, falls back to legacy properties for migration - func buildContactFieldsArray(from card: BusinessCard) -> [AddedContactField] { - // If the card has the new contactFields array, use it - if let modelFields = card.contactFields, !modelFields.isEmpty { - return card.orderedContactFields.compactMap { $0.toAddedContactField() } - } - - // Fall back to legacy properties for migration - var fields: [AddedContactField] = [] - - if !card.email.isEmpty { - fields.append(AddedContactField(fieldType: .email, value: card.email, title: card.emailLabel)) - } - if !card.phone.isEmpty { - let phoneValue = card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)" - fields.append(AddedContactField(fieldType: .phone, value: phoneValue, title: card.phoneLabel)) - } - if !card.website.isEmpty { - fields.append(AddedContactField(fieldType: .website, value: card.website, title: "")) - } - if !card.location.isEmpty { - fields.append(AddedContactField(fieldType: .address, value: card.location, title: "")) - } - if !card.linkedIn.isEmpty { - fields.append(AddedContactField(fieldType: .linkedIn, value: card.linkedIn, title: "")) - } - if !card.twitter.isEmpty { - fields.append(AddedContactField(fieldType: .twitter, value: card.twitter, title: "")) - } - if !card.instagram.isEmpty { - fields.append(AddedContactField(fieldType: .instagram, value: card.instagram, title: "")) - } - if !card.facebook.isEmpty { - fields.append(AddedContactField(fieldType: .facebook, value: card.facebook, title: "")) - } - if !card.tiktok.isEmpty { - fields.append(AddedContactField(fieldType: .tiktok, value: card.tiktok, title: "")) - } - if !card.github.isEmpty { - fields.append(AddedContactField(fieldType: .github, value: card.github, title: "")) - } - if !card.threads.isEmpty { - fields.append(AddedContactField(fieldType: .threads, value: card.threads, title: "")) - } - if !card.telegram.isEmpty { - fields.append(AddedContactField(fieldType: .telegram, value: card.telegram, title: "")) - } - if !card.venmo.isEmpty { - fields.append(AddedContactField(fieldType: .venmo, value: card.venmo, title: "")) - } - if !card.cashApp.isEmpty { - fields.append(AddedContactField(fieldType: .cashApp, value: card.cashApp, title: "")) - } - if !card.customLink1URL.isEmpty { - fields.append(AddedContactField(fieldType: .customLink, value: card.customLink1URL, title: card.customLink1Title)) - } - if !card.customLink2URL.isEmpty { - fields.append(AddedContactField(fieldType: .customLink, value: card.customLink2URL, title: card.customLink2Title)) - } - - return fields - } - /// Saves the contactFields array to the BusinessCard model func saveContactFieldsToCard(_ card: BusinessCard) { // Clear existing contact fields @@ -1256,77 +781,6 @@ private extension CardEditorView { card.contactFields?.append(field) } } - - /// Syncs the contactFields array back to individual properties - func syncContactFieldsToProperties() { - // Reset all contact properties - email = ""; emailLabel = "Work" - phone = ""; phoneLabel = "Cell"; phoneExtension = "" - website = ""; location = "" - linkedIn = ""; twitter = ""; instagram = ""; facebook = "" - tiktok = ""; github = ""; threads = ""; telegram = "" - venmo = ""; cashApp = "" - customLink1Title = ""; customLink1URL = "" - customLink2Title = ""; customLink2URL = "" - - var customLinkCount = 0 - - for field in contactFields { - let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty else { continue } - - switch field.fieldType.id { - case "email": - // Take first non-empty email - if email.isEmpty { - email = value - emailLabel = field.title.isEmpty ? "Work" : field.title - } - case "phone": - // Take first non-empty phone - if phone.isEmpty { - phone = value - phoneLabel = field.title.isEmpty ? "Cell" : field.title - } - case "website": - if website.isEmpty { website = value } - case "address": - if location.isEmpty { location = value } - case "linkedIn": - if linkedIn.isEmpty { linkedIn = value } - case "twitter": - if twitter.isEmpty { twitter = value } - case "instagram": - if instagram.isEmpty { instagram = value } - case "facebook": - if facebook.isEmpty { facebook = value } - case "tiktok": - if tiktok.isEmpty { tiktok = value } - case "github": - if github.isEmpty { github = value } - case "threads": - if threads.isEmpty { threads = value } - case "telegram": - if telegram.isEmpty { telegram = value } - case "venmo": - if venmo.isEmpty { venmo = value } - case "cashApp": - if cashApp.isEmpty { cashApp = value } - case "customLink": - if customLinkCount == 0 { - customLink1URL = value - customLink1Title = field.title - customLinkCount += 1 - } else if customLinkCount == 1 { - customLink2URL = value - customLink2Title = field.title - customLinkCount += 1 - } - default: - break - } - } - } } // MARK: - Preview diff --git a/BusinessCard/Views/ContactDetailView.swift b/BusinessCard/Views/ContactDetailView.swift index 8a42306..42ba798 100644 --- a/BusinessCard/Views/ContactDetailView.swift +++ b/BusinessCard/Views/ContactDetailView.swift @@ -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 { diff --git a/BusinessCard/Views/Sheets/AddContactSheet.swift b/BusinessCard/Views/Sheets/AddContactSheet.swift index 40c1014..ed14907 100644 --- a/BusinessCard/Views/Sheets/AddContactSheet.swift +++ b/BusinessCard/Views/Sheets/AddContactSheet.swift @@ -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) } } } diff --git a/BusinessCardTests/BusinessCardTests.swift b/BusinessCardTests/BusinessCardTests.swift index e92e0a5..4bf2a6a 100644 --- a/BusinessCardTests/BusinessCardTests.swift +++ b/BusinessCardTests/BusinessCardTests.swift @@ -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", diff --git a/README.md b/README.md index fe5000e..f912c8f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ai_implmentation.md b/ai_implmentation.md index 6cc3881..2cb8049 100644 --- a/ai_implmentation.md +++ b/ai_implmentation.md @@ -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