Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
77f70386d2
commit
874908398a
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ import Foundation
|
||||
|
||||
enum AppTab: String, CaseIterable, Hashable, Identifiable {
|
||||
case cards
|
||||
case share
|
||||
case contacts
|
||||
case widgets
|
||||
|
||||
|
||||
@ -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"
|
||||
]
|
||||
|
||||
// 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))")
|
||||
}
|
||||
|
||||
// ORG: Organization - can include department
|
||||
if !company.isEmpty {
|
||||
if !department.isEmpty {
|
||||
lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))")
|
||||
} else {
|
||||
lines.append("ORG:\(escapeVCardValue(company))")
|
||||
}
|
||||
}
|
||||
|
||||
// TITLE: Job title/role
|
||||
if !role.isEmpty {
|
||||
lines.append("TITLE:\(escapeVCardValue(role))")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
lines.append("TEL;TYPE=work:\(phone)")
|
||||
let typeLabel = phoneLabel.isEmpty ? "CELL" : phoneLabel.uppercased()
|
||||
lines.append("TEL;TYPE=\(typeLabel):\(escapeVCardValue(phone))")
|
||||
}
|
||||
if !email.isEmpty {
|
||||
lines.append("EMAIL;TYPE=work:\(email)")
|
||||
let typeLabel = emailLabel.isEmpty ? "WORK" : emailLabel.uppercased()
|
||||
lines.append("EMAIL;TYPE=\(typeLabel):\(escapeVCardValue(email))")
|
||||
}
|
||||
if !website.isEmpty {
|
||||
lines.append("URL:\(website)")
|
||||
lines.append("URL:\(escapeVCardValue(website))")
|
||||
}
|
||||
if !location.isEmpty {
|
||||
lines.append("ADR;TYPE=work:;;\(location)")
|
||||
}
|
||||
if !bio.isEmpty {
|
||||
lines.append("NOTE:\(bio)")
|
||||
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(location));;;;")
|
||||
}
|
||||
if !linkedIn.isEmpty {
|
||||
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(linkedIn)")
|
||||
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(linkedIn))")
|
||||
}
|
||||
if !twitter.isEmpty {
|
||||
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(twitter)")
|
||||
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(escapeVCardValue(twitter))")
|
||||
}
|
||||
if !instagram.isEmpty {
|
||||
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(instagram)")
|
||||
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 !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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,18 +4,17 @@ import SwiftData
|
||||
|
||||
struct RootTabView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var showingShareSheet = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
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()
|
||||
}
|
||||
@ -24,6 +23,46 @@ struct RootTabView: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user