diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift index 0e09547..e6952dc 100644 --- a/BusinessCard/Design/DesignConstants.swift +++ b/BusinessCard/Design/DesignConstants.swift @@ -28,6 +28,7 @@ extension Design { static let widgetPhoneWidth: CGFloat = 220 static let widgetPhoneHeight: CGFloat = 120 static let widgetWatchSize: CGFloat = 100 + static let floatingButtonSize: CGFloat = 56 } } diff --git a/BusinessCard/Models/AppTab.swift b/BusinessCard/Models/AppTab.swift index 21e7060..b243a36 100644 --- a/BusinessCard/Models/AppTab.swift +++ b/BusinessCard/Models/AppTab.swift @@ -2,7 +2,6 @@ import Foundation enum AppTab: String, CaseIterable, Hashable, Identifiable { case cards - case share case contacts case widgets diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift index 4d4dc3b..f455ce2 100644 --- a/BusinessCard/Models/BusinessCard.swift +++ b/BusinessCard/Models/BusinessCard.swift @@ -331,39 +331,197 @@ final class BusinessCard { var vCardPayload: String { var lines = [ "BEGIN:VCARD", - "VERSION:3.0", - "FN:\(displayName)", - "ORG:\(company)", - "TITLE:\(role)" + "VERSION:3.0" ] - if !phone.isEmpty { - lines.append("TEL;TYPE=work:\(phone)") + // 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 + let formattedName = simpleName.isEmpty ? displayName : simpleName + lines.append("FN:\(escapeVCardValue(formattedName))") + + // NICKNAME: Preferred name + if !preferredName.isEmpty { + lines.append("NICKNAME:\(escapeVCardValue(preferredName))") } - if !email.isEmpty { - lines.append("EMAIL;TYPE=work:\(email)") + + // ORG: Organization - can include department + if !company.isEmpty { + if !department.isEmpty { + lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))") + } else { + lines.append("ORG:\(escapeVCardValue(company))") + } } - if !website.isEmpty { - lines.append("URL:\(website)") + + // TITLE: Job title/role + if !role.isEmpty { + lines.append("TITLE:\(escapeVCardValue(role))") } - if !location.isEmpty { - lines.append("ADR;TYPE=work:;;\(location)") + + // Contact fields from the new array (preferred) + for field in orderedContactFields { + let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { continue } + + switch field.typeId { + case "email": + let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased() + lines.append("EMAIL;TYPE=\(typeLabel):\(escapeVCardValue(value))") + case "phone": + let typeLabel = field.title.isEmpty ? "CELL" : field.title.uppercased() + lines.append("TEL;TYPE=\(typeLabel):\(escapeVCardValue(value))") + case "website": + lines.append("URL:\(escapeVCardValue(value))") + case "address": + lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(value));;;;") + case "linkedIn": + lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(value))") + case "twitter": + lines.append("X-SOCIALPROFILE;TYPE=twitter:\(escapeVCardValue(value))") + case "instagram": + lines.append("X-SOCIALPROFILE;TYPE=instagram:\(escapeVCardValue(value))") + case "facebook": + lines.append("X-SOCIALPROFILE;TYPE=facebook:\(escapeVCardValue(value))") + case "tiktok": + lines.append("X-SOCIALPROFILE;TYPE=tiktok:\(escapeVCardValue(value))") + case "github": + lines.append("X-SOCIALPROFILE;TYPE=github:\(escapeVCardValue(value))") + case "threads": + lines.append("X-SOCIALPROFILE;TYPE=threads:\(escapeVCardValue(value))") + case "telegram": + lines.append("X-SOCIALPROFILE;TYPE=telegram:\(escapeVCardValue(value))") + case "bluesky": + lines.append("X-SOCIALPROFILE;TYPE=bluesky:\(escapeVCardValue(value))") + case "mastodon": + lines.append("X-SOCIALPROFILE;TYPE=mastodon:\(escapeVCardValue(value))") + case "youtube": + lines.append("X-SOCIALPROFILE;TYPE=youtube:\(escapeVCardValue(value))") + case "twitch": + lines.append("X-SOCIALPROFILE;TYPE=twitch:\(escapeVCardValue(value))") + case "reddit": + lines.append("X-SOCIALPROFILE;TYPE=reddit:\(escapeVCardValue(value))") + case "snapchat": + lines.append("X-SOCIALPROFILE;TYPE=snapchat:\(escapeVCardValue(value))") + case "pinterest": + lines.append("X-SOCIALPROFILE;TYPE=pinterest:\(escapeVCardValue(value))") + case "discord": + lines.append("X-SOCIALPROFILE;TYPE=discord:\(escapeVCardValue(value))") + case "slack": + lines.append("X-SOCIALPROFILE;TYPE=slack:\(escapeVCardValue(value))") + case "whatsapp": + lines.append("X-SOCIALPROFILE;TYPE=whatsapp:\(escapeVCardValue(value))") + case "signal": + lines.append("X-SOCIALPROFILE;TYPE=signal:\(escapeVCardValue(value))") + case "venmo": + lines.append("X-SOCIALPROFILE;TYPE=venmo:\(escapeVCardValue(value))") + case "cashApp": + lines.append("X-SOCIALPROFILE;TYPE=cashapp:\(escapeVCardValue(value))") + case "paypal": + lines.append("X-SOCIALPROFILE;TYPE=paypal:\(escapeVCardValue(value))") + case "calendly": + lines.append("URL;TYPE=calendly:\(escapeVCardValue(value))") + case "customLink": + 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 { - lines.append("NOTE:\(bio)") + notes.append(bio) } - if !linkedIn.isEmpty { - lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(linkedIn)") + if !accreditations.isEmpty { + notes.append("Credentials: \(accreditations)") } - if !twitter.isEmpty { - lines.append("X-SOCIALPROFILE;TYPE=twitter:\(twitter)") + if !pronouns.isEmpty { + notes.append("Pronouns: \(pronouns)") } - if !instagram.isEmpty { - lines.append("X-SOCIALPROFILE;TYPE=instagram:\(instagram)") + if !notes.isEmpty { + lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))") } lines.append("END:VCARD") - return lines.joined(separator: "\n") + return lines.joined(separator: "\r\n") + } + + /// Escapes special characters for vCard format + private func escapeVCardValue(_ value: String) -> String { + value + .replacing("\\", with: "\\\\") + .replacing(",", with: "\\,") + .replacing(";", with: "\\;") + .replacing("\n", with: "\\n") } } diff --git a/BusinessCard/Views/RootTabView.swift b/BusinessCard/Views/RootTabView.swift index 8d50b81..31989f1 100644 --- a/BusinessCard/Views/RootTabView.swift +++ b/BusinessCard/Views/RootTabView.swift @@ -4,26 +4,65 @@ import SwiftData struct RootTabView: View { @Environment(AppState.self) private var appState + @State private var showingShareSheet = false var body: some View { @Bindable var appState = appState - TabView(selection: $appState.selectedTab) { - Tab(String.localized("My Cards"), systemImage: "rectangle.stack", value: AppTab.cards) { - CardsHomeView() - } + + ZStack(alignment: .bottom) { + TabView(selection: $appState.selectedTab) { + Tab(String.localized("My Cards"), systemImage: "rectangle.stack", value: AppTab.cards) { + CardsHomeView() + } - Tab(String.localized("Share"), systemImage: "qrcode", value: AppTab.share) { - ShareCardView() - } + Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) { + ContactsView() + } - Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) { - ContactsView() + Tab(String.localized("Widgets"), systemImage: "square.grid.2x2", value: AppTab.widgets) { + WidgetsView() + } } - - Tab(String.localized("Widgets"), systemImage: "square.grid.2x2", value: AppTab.widgets) { - WidgetsView() + + // Floating share button - hidden when no cards exist + if !appState.cardStore.cards.isEmpty { + FloatingShareButton { + showingShareSheet = true + } } } + .sheet(isPresented: $showingShareSheet) { + ShareCardView() + } + } +} + +// MARK: - Floating Share Button + +private struct FloatingShareButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "qrcode") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + .frame(width: Design.CardSize.floatingButtonSize, height: Design.CardSize.floatingButtonSize) + .background( + Circle() + .fill(Color.Accent.red) + .shadow( + color: Color.Accent.red.opacity(Design.Opacity.medium), + radius: Design.Shadow.radiusMedium, + x: Design.Shadow.offsetNone, + y: Design.Shadow.offsetSmall + ) + ) + } + .accessibilityLabel(String.localized("Share")) + .accessibilityHint(String.localized("Opens the share sheet to send your card")) + .padding(.bottom, Design.Spacing.xxLarge + Design.Spacing.xLarge) } } diff --git a/BusinessCardTests/BusinessCardTests.swift b/BusinessCardTests/BusinessCardTests.swift index 890e932..e92e0a5 100644 --- a/BusinessCardTests/BusinessCardTests.swift +++ b/BusinessCardTests/BusinessCardTests.swift @@ -28,11 +28,65 @@ struct BusinessCardTests { context.insert(card) #expect(card.vCardPayload.contains("BEGIN:VCARD")) - #expect(card.vCardPayload.contains("FN:\(card.displayName)")) - #expect(card.vCardPayload.contains("ORG:\(card.company)")) - #expect(card.vCardPayload.contains("EMAIL;TYPE=work:\(card.email)")) - #expect(card.vCardPayload.contains("TEL;TYPE=work:\(card.phone)")) - #expect(card.vCardPayload.contains("NOTE:\(card.bio)")) + #expect(card.vCardPayload.contains("FN:Test User")) + #expect(card.vCardPayload.contains("ORG:Test Corp")) + #expect(card.vCardPayload.contains("EMAIL;TYPE=WORK:test@example.com")) + #expect(card.vCardPayload.contains("TEL;TYPE=CELL:+1 555 123 4567")) + #expect(card.vCardPayload.contains("A passionate developer")) + #expect(card.vCardPayload.contains("END:VCARD")) + } + + @Test func vCardPayloadIncludesStructuredName() async throws { + let container = try makeTestContainer() + let context = container.mainContext + + let card = BusinessCard( + prefix: "Dr.", + firstName: "John", + middleName: "Michael", + lastName: "Smith", + suffix: "Jr." + ) + context.insert(card) + + // N: LastName;FirstName;MiddleName;Prefix;Suffix + #expect(card.vCardPayload.contains("N:Smith;John;Michael;Dr.;Jr.")) + #expect(card.vCardPayload.contains("FN:Dr. John Michael Smith Jr.")) + } + + @Test func vCardPayloadIncludesAllContactInfo() async throws { + let container = try makeTestContainer() + let context = container.mainContext + + let card = BusinessCard( + role: "CEO", + company: "Acme Inc", + prefix: "", + firstName: "Jane", + lastName: "Doe", + pronouns: "she/her", + department: "Executive", + headline: "Building the future", + bio: "Passionate leader", + accreditations: "MBA, CPA" + ) + context.insert(card) + + // Verify structured name + #expect(card.vCardPayload.contains("N:Doe;Jane;;;")) + #expect(card.vCardPayload.contains("FN:Jane Doe")) + + // Verify org with department + #expect(card.vCardPayload.contains("ORG:Acme Inc;Executive")) + + // Verify title + #expect(card.vCardPayload.contains("TITLE:CEO")) + + // Verify note contains all info + #expect(card.vCardPayload.contains("Building the future")) + #expect(card.vCardPayload.contains("Passionate leader")) + #expect(card.vCardPayload.contains("Credentials: MBA\\, CPA")) + #expect(card.vCardPayload.contains("Pronouns: she/her")) } @Test func defaultCardSelectionUpdatesCards() async throws { diff --git a/README.md b/README.md index 164e394..478eb39 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,12 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with ### Share +- **Floating share button**: Centered above the tab bar for quick access - **Dark-themed share sheet**: Sleek QR code display with prominent sharing options - Share options: copy link, SMS, email, WhatsApp, LinkedIn, and more - **Offline sharing toggle**: Share your card without internet connection - **Track shares**: Record who received your card and when +- Button is hidden when no cards exist - Placeholder actions for Apple Wallet and NFC (alerts included) ### Card Editor diff --git a/ai_implmentation.md b/ai_implmentation.md index 94dc1b3..c8fb6f0 100644 --- a/ai_implmentation.md +++ b/ai_implmentation.md @@ -104,9 +104,9 @@ App-specific extensions are in `Design/DesignConstants.swift`: ### Views Main screens: -- `Views/RootTabView.swift` — tabbed shell (4 tabs: My Cards, Share, Contacts, Widgets) +- `Views/RootTabView.swift` — tabbed shell (3 tabs: My Cards, Contacts, Widgets) + floating share button - `Views/CardsHomeView.swift` — full-screen swipeable card view with edit button -- `Views/ShareCardView.swift` — QR + share actions + track share +- `Views/ShareCardView.swift` — QR + share actions + track share (opened as sheet from floating button) - `Views/ContactsView.swift` — contact list with sections - `Views/WidgetsView.swift` — widget preview mockups