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

This commit is contained in:
Matt Bruce 2026-01-08 22:42:16 -06:00
parent 77f70386d2
commit 874908398a
7 changed files with 293 additions and 40 deletions

View File

@ -28,6 +28,7 @@ extension Design {
static let widgetPhoneWidth: CGFloat = 220 static let widgetPhoneWidth: CGFloat = 220
static let widgetPhoneHeight: CGFloat = 120 static let widgetPhoneHeight: CGFloat = 120
static let widgetWatchSize: CGFloat = 100 static let widgetWatchSize: CGFloat = 100
static let floatingButtonSize: CGFloat = 56
} }
} }

View File

@ -2,7 +2,6 @@ import Foundation
enum AppTab: String, CaseIterable, Hashable, Identifiable { enum AppTab: String, CaseIterable, Hashable, Identifiable {
case cards case cards
case share
case contacts case contacts
case widgets case widgets

View File

@ -331,39 +331,197 @@ final class BusinessCard {
var vCardPayload: String { var vCardPayload: String {
var lines = [ var lines = [
"BEGIN:VCARD", "BEGIN:VCARD",
"VERSION:3.0", "VERSION:3.0"
"FN:\(displayName)",
"ORG:\(company)",
"TITLE:\(role)"
] ]
if !phone.isEmpty { // N: Structured name - REQUIRED for proper contact import
lines.append("TEL;TYPE=work:\(phone)") // 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 { if !bio.isEmpty {
lines.append("NOTE:\(bio)") notes.append(bio)
} }
if !linkedIn.isEmpty { if !accreditations.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(linkedIn)") notes.append("Credentials: \(accreditations)")
} }
if !twitter.isEmpty { if !pronouns.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(twitter)") notes.append("Pronouns: \(pronouns)")
} }
if !instagram.isEmpty { if !notes.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(instagram)") lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))")
} }
lines.append("END:VCARD") 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")
} }
} }

View File

@ -4,26 +4,65 @@ import SwiftData
struct RootTabView: View { struct RootTabView: View {
@Environment(AppState.self) private var appState @Environment(AppState.self) private var appState
@State private var showingShareSheet = false
var body: some View { var body: some View {
@Bindable var appState = appState @Bindable var appState = appState
TabView(selection: $appState.selectedTab) {
Tab(String.localized("My Cards"), systemImage: "rectangle.stack", value: AppTab.cards) { ZStack(alignment: .bottom) {
CardsHomeView() TabView(selection: $appState.selectedTab) {
Tab(String.localized("My Cards"), systemImage: "rectangle.stack", value: AppTab.cards) {
CardsHomeView()
}
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("Share"), systemImage: "qrcode", value: AppTab.share) { // Floating share button - hidden when no cards exist
ShareCardView() if !appState.cardStore.cards.isEmpty {
} FloatingShareButton {
showingShareSheet = true
Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) { }
ContactsView()
}
Tab(String.localized("Widgets"), systemImage: "square.grid.2x2", value: AppTab.widgets) {
WidgetsView()
} }
} }
.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)
} }
} }

View File

@ -28,11 +28,65 @@ struct BusinessCardTests {
context.insert(card) context.insert(card)
#expect(card.vCardPayload.contains("BEGIN:VCARD")) #expect(card.vCardPayload.contains("BEGIN:VCARD"))
#expect(card.vCardPayload.contains("FN:\(card.displayName)")) #expect(card.vCardPayload.contains("FN:Test User"))
#expect(card.vCardPayload.contains("ORG:\(card.company)")) #expect(card.vCardPayload.contains("ORG:Test Corp"))
#expect(card.vCardPayload.contains("EMAIL;TYPE=work:\(card.email)")) #expect(card.vCardPayload.contains("EMAIL;TYPE=WORK:test@example.com"))
#expect(card.vCardPayload.contains("TEL;TYPE=work:\(card.phone)")) #expect(card.vCardPayload.contains("TEL;TYPE=CELL:+1 555 123 4567"))
#expect(card.vCardPayload.contains("NOTE:\(card.bio)")) #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 { @Test func defaultCardSelectionUpdatesCards() async throws {

View File

@ -24,10 +24,12 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
### Share ### Share
- **Floating share button**: Centered above the tab bar for quick access
- **Dark-themed share sheet**: Sleek QR code display with prominent sharing options - **Dark-themed share sheet**: Sleek QR code display with prominent sharing options
- Share options: copy link, SMS, email, WhatsApp, LinkedIn, and more - Share options: copy link, SMS, email, WhatsApp, LinkedIn, and more
- **Offline sharing toggle**: Share your card without internet connection - **Offline sharing toggle**: Share your card without internet connection
- **Track shares**: Record who received your card and when - **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) - Placeholder actions for Apple Wallet and NFC (alerts included)
### Card Editor ### Card Editor

View File

@ -104,9 +104,9 @@ App-specific extensions are in `Design/DesignConstants.swift`:
### Views ### Views
Main screens: 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/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/ContactsView.swift` — contact list with sections
- `Views/WidgetsView.swift` — widget preview mockups - `Views/WidgetsView.swift` — widget preview mockups