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

This commit is contained in:
Matt Bruce 2026-01-08 22:27:47 -06:00
parent b26f15cec6
commit 77f70386d2
11 changed files with 271 additions and 569 deletions

View File

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

View File

@ -43,9 +43,6 @@
} }
} }
} }
},
"+1 555 123 4567" : {
}, },
"About" : { "About" : {
@ -57,6 +54,7 @@
}, },
"Add a QR widget so your card is always one tap away." : { "Add a QR widget so your card is always one tap away." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -126,6 +124,7 @@
}, },
"Change image layout" : { "Change image layout" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -160,6 +159,7 @@
}, },
"Create multiple business cards" : { "Create multiple business cards" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -180,11 +180,15 @@
} }
} }
} }
},
"Create your first card" : {
}, },
"Custom Links" : { "Custom Links" : {
}, },
"Customize your card" : { "Customize your card" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -211,6 +215,9 @@
}, },
"Delete Field" : { "Delete Field" : {
},
"Design and share polished digital business cards for every context." : {
}, },
"Developer" : { "Developer" : {
@ -229,9 +236,6 @@
}, },
"Email or Username" : { "Email or Username" : {
},
"Example" : {
}, },
"Ext." : { "Ext." : {
@ -508,9 +512,6 @@
}, },
"Support & Funding" : { "Support & Funding" : {
},
"Tap \"New Card\" to create your first card" : {
}, },
"Tap a field below to add it" : { "Tap a field below to add it" : {
@ -544,6 +545,7 @@
}, },
"The #1 Digital Business Card App" : { "The #1 Digital Business Card App" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -581,6 +583,7 @@
}, },
"Used by Industry Leaders" : { "Used by Industry Leaders" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -660,21 +663,6 @@
}, },
"Work" : { "Work" : {
},
"Your card will appear here" : {
},
"Your Company" : {
},
"Your Name" : {
},
"Your Role" : {
},
"your@email.com" : {
} }
}, },
"version" : "1.1" "version" : "1.1"

View File

@ -1,151 +0,0 @@
import SwiftUI
import Bedrock
import SwiftData
struct CardCarouselView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var cardStore = appState.cardStore
let hasCards = !cardStore.cards.isEmpty
VStack(spacing: Design.Spacing.medium) {
HStack {
Text(hasCards ? "Create multiple business cards" : "Your card will appear here")
.font(.headline)
.bold()
.foregroundStyle(Color.Text.primary)
Spacer()
}
if hasCards {
TabView(selection: $cardStore.selectedCardID) {
ForEach(cardStore.cards) { card in
BusinessCardView(card: card, isCompact: true)
.tag(Optional(card.id))
.padding(.vertical, Design.Spacing.medium)
}
}
.tabViewStyle(.page)
.frame(height: Design.CardSize.cardHeightCompact + Design.Spacing.xxLarge)
if let selected = cardStore.selectedCard {
CardDefaultToggleView(card: selected) {
cardStore.setDefaultCard(selected)
}
}
} else {
DemoCardView()
.padding(.vertical, Design.Spacing.medium)
}
}
}
}
private struct CardDefaultToggleView: View {
let card: BusinessCard
let action: () -> Void
var body: some View {
Button(
card.isDefault ? String.localized("Default card") : String.localized("Set as default"),
systemImage: card.isDefault ? "checkmark.seal.fill" : "checkmark.seal",
action: action
)
.buttonStyle(.bordered)
.tint(Color.Accent.red)
.accessibilityHint(String.localized("Sets this card as your default sharing card"))
}
}
/// A demo card shown when the user has no cards yet.
/// Displays placeholder content with an "Example" badge to prompt card creation.
private struct DemoCardView: View {
var body: some View {
VStack(spacing: Design.Spacing.medium) {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack(spacing: Design.Spacing.medium) {
Circle()
.fill(Color.AppText.inverted)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
Image(systemName: "person.crop.circle")
.foregroundStyle(Color.CardPalette.coral)
)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Your Name")
.font(.headline)
.bold()
.foregroundStyle(Color.AppText.inverted)
Text("Your Role")
.font(.subheadline)
.foregroundStyle(Color.AppText.inverted.opacity(Design.Opacity.almostFull))
Text("Your Company")
.font(.caption)
.foregroundStyle(Color.AppText.inverted.opacity(Design.Opacity.medium))
}
Spacer(minLength: Design.Spacing.small)
Text("Example")
.font(.caption)
.bold()
.foregroundStyle(Color.AppText.inverted)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall)
.background(Color.AppText.inverted.opacity(Design.Opacity.hint))
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
Divider()
.overlay(Color.AppText.inverted.opacity(Design.Opacity.medium))
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "envelope")
.font(.caption)
Text("your@email.com")
.font(.caption)
}
.foregroundStyle(Color.AppText.inverted.opacity(Design.Opacity.heavy))
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "phone")
.font(.caption)
Text("+1 555 123 4567")
.font(.caption)
}
.foregroundStyle(Color.AppText.inverted.opacity(Design.Opacity.heavy))
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity)
.background(
LinearGradient(
colors: [Color.CardPalette.coral, Color.CardPalette.sand],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
.shadow(
color: Color.AppText.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusLarge,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetMedium
)
.opacity(Design.Opacity.strong)
Text("Tap \"New Card\" to create your first card")
.font(.caption)
.foregroundStyle(Color.AppText.secondary)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String.localized("Example business card"))
.accessibilityHint(String.localized("Create a new card to replace this example"))
}
}
#Preview {
CardCarouselView()
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
}

View File

@ -5,81 +5,156 @@ import SwiftData
struct CardsHomeView: View { struct CardsHomeView: View {
@Environment(AppState.self) private var appState @Environment(AppState.self) private var appState
@State private var showingCreateCard = false @State private var showingCreateCard = false
@State private var showingEditCard = false
@State private var showingDeleteConfirmation = false
var body: some View { var body: some View {
@Bindable var cardStore = appState.cardStore
NavigationStack { NavigationStack {
ScrollView { ZStack {
VStack(spacing: Design.Spacing.xLarge) { // Background gradient
HeroBannerView()
SectionTitleView(
title: String.localized("Create your digital business card"),
subtitle: String.localized("Design and share polished cards for every context.")
)
CardCarouselView()
HStack(spacing: Design.Spacing.medium) {
if appState.cardStore.cards.isEmpty {
PrimaryActionButton(
title: String.localized("Create Card"),
systemImage: "plus"
) {
showingCreateCard = true
}
} else {
PrimaryActionButton(
title: String.localized("Send my card"),
systemImage: "paperplane.fill"
) {
appState.selectedTab = .share
}
Button(String.localized("New Card"), systemImage: "plus") {
showingCreateCard = true
}
.buttonStyle(.bordered)
.tint(Color.Accent.ink)
.controlSize(.large)
.accessibilityHint(String.localized("Create a new business card"))
}
}
WidgetsCalloutView()
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.xLarge)
}
.background(
LinearGradient( LinearGradient(
colors: [Color.AppBackground.base, Color.AppBackground.accent], colors: [Color.AppBackground.base, Color.AppBackground.accent],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
) )
) .ignoresSafeArea()
.navigationTitle(String.localized("My Cards"))
if cardStore.cards.isEmpty {
EmptyCardsView(onCreateCard: { showingCreateCard = true })
} else {
// Full-screen swipeable cards
TabView(selection: $cardStore.selectedCardID) {
ForEach(cardStore.cards) { card in
CardPageView(
card: card,
isDefault: card.isDefault,
onSetDefault: { cardStore.setDefaultCard(card) }
)
.tag(Optional(card.id))
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.indexViewStyle(.page(backgroundDisplayMode: .automatic))
}
}
.navigationTitle(cardStore.selectedCard?.label ?? String.localized("My Cards"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String.localized("Add Card"), systemImage: "plus") {
showingCreateCard = true
}
.accessibilityHint(String.localized("Create a new business card"))
}
ToolbarItemGroup(placement: .topBarTrailing) {
if cardStore.selectedCard != nil {
if cardStore.cards.count > 1 {
Button(String.localized("Delete"), systemImage: "trash") {
showingDeleteConfirmation = true
}
.accessibilityHint(String.localized("Delete this card"))
}
Button(String.localized("Edit"), systemImage: "pencil") {
showingEditCard = true
}
.accessibilityHint(String.localized("Edit this card"))
}
}
}
.sheet(isPresented: $showingCreateCard) { .sheet(isPresented: $showingCreateCard) {
CardEditorView(card: nil) { newCard in CardEditorView(card: nil) { newCard in
appState.cardStore.addCard(newCard) appState.cardStore.addCard(newCard)
} }
} }
.sheet(isPresented: $showingEditCard) {
if let card = cardStore.selectedCard {
CardEditorView(card: card) { updatedCard in
appState.cardStore.updateCard(updatedCard)
}
}
}
.alert(String.localized("Delete Card"), isPresented: $showingDeleteConfirmation) {
Button(String.localized("Cancel"), role: .cancel) { }
Button(String.localized("Delete"), role: .destructive) {
if let card = cardStore.selectedCard {
appState.cardStore.deleteCard(card)
}
}
} message: {
Text("Are you sure you want to delete this card? This action cannot be undone.")
}
} }
} }
} }
private struct SectionTitleView: View { // MARK: - Card Page View
let title: String
let subtitle: String private struct CardPageView: View {
let card: BusinessCard
let isDefault: Bool
let onSetDefault: () -> Void
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) { ScrollView {
Text(title) VStack(spacing: Design.Spacing.large) {
.font(.title3) BusinessCardView(card: card)
.bold()
.foregroundStyle(Color.Text.primary) // Default card toggle
Text(subtitle) Button(
.font(.subheadline) isDefault ? String.localized("Default card") : String.localized("Set as default"),
.foregroundStyle(Color.Text.secondary) systemImage: isDefault ? "checkmark.seal.fill" : "checkmark.seal",
action: onSetDefault
)
.buttonStyle(.bordered)
.tint(Color.Accent.red)
.accessibilityHint(String.localized("Sets this card as your default sharing card"))
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.xLarge)
}
.scrollIndicators(.hidden)
}
}
// MARK: - Empty State
private struct EmptyCardsView: View {
let onCreateCard: () -> Void
var body: some View {
VStack(spacing: Design.Spacing.xLarge) {
Spacer()
Image(systemName: "rectangle.stack.badge.plus")
.font(.system(size: Design.BaseFontSize.display))
.foregroundStyle(Color.Text.secondary)
VStack(spacing: Design.Spacing.small) {
Text("Create your first card")
.font(.title2)
.bold()
.foregroundStyle(Color.Text.primary)
Text("Design and share polished digital business cards for every context.")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, Design.Spacing.xLarge)
PrimaryActionButton(
title: String.localized("Create Card"),
systemImage: "plus"
) {
onCreateCard()
}
Spacer()
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }

View File

@ -1,172 +0,0 @@
import SwiftUI
import Bedrock
import SwiftData
struct CustomizeCardView: View {
@Environment(AppState.self) private var appState
@State private var showingEditCard = false
@State private var showingDeleteConfirmation = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.large) {
Text("Customize your card")
.font(.title2)
.bold()
.foregroundStyle(Color.Text.primary)
if let card = appState.cardStore.selectedCard {
BusinessCardView(card: card)
CardActionsView(
onEdit: { showingEditCard = true },
onDelete: { showingDeleteConfirmation = true },
canDelete: appState.cardStore.cards.count > 1
)
CardStylePickerView(selectedTheme: card.theme) { theme in
appState.cardStore.setSelectedTheme(theme)
}
CardLayoutPickerView(selectedLayout: card.layoutStyle) { layout in
appState.cardStore.setSelectedLayout(layout)
}
} else {
EmptyStateView(
title: String.localized("No card selected"),
message: String.localized("Select a card to start customizing.")
)
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.xLarge)
}
.background(Color.AppBackground.base)
.navigationTitle(String.localized("Edit your card"))
.sheet(isPresented: $showingEditCard) {
if let card = appState.cardStore.selectedCard {
CardEditorView(card: card) { updatedCard in
appState.cardStore.updateCard(updatedCard)
}
}
}
.alert(String.localized("Delete Card"), isPresented: $showingDeleteConfirmation) {
Button(String.localized("Cancel"), role: .cancel) { }
Button(String.localized("Delete"), role: .destructive) {
if let card = appState.cardStore.selectedCard {
appState.cardStore.deleteCard(card)
}
}
} message: {
Text("Are you sure you want to delete this card? This action cannot be undone.")
}
}
}
}
private struct CardActionsView: View {
let onEdit: () -> Void
let onDelete: () -> Void
let canDelete: Bool
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Button(String.localized("Edit Details"), systemImage: "pencil", action: onEdit)
.buttonStyle(.bordered)
.tint(Color.Accent.ink)
.accessibilityHint(String.localized("Edit card name, email, and other details"))
if canDelete {
Button(String.localized("Delete"), systemImage: "trash", role: .destructive, action: onDelete)
.buttonStyle(.bordered)
.accessibilityHint(String.localized("Permanently delete this card"))
}
}
.padding(.vertical, Design.Spacing.small)
}
}
private struct CardStylePickerView: View {
let selectedTheme: CardTheme
let onSelect: (CardTheme) -> Void
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
Text("Card style")
.font(.headline)
.bold()
.foregroundStyle(Color.Text.primary)
LazyVGrid(columns: gridColumns, spacing: Design.Spacing.small) {
ForEach(CardTheme.all) { theme in
Button(action: { onSelect(theme) }) {
VStack(spacing: Design.Spacing.xSmall) {
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(theme.primaryColor)
.frame(height: Design.CardSize.avatarSize)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(
selectedTheme.id == theme.id ? Color.Accent.red : Color.Text.inverted.opacity(Design.Opacity.medium),
lineWidth: Design.LineWidth.medium
)
)
Text(theme.localizedName)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.accessibilityLabel(String.localized("Card style"))
.accessibilityValue(theme.localizedName)
}
}
}
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
private var gridColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.small), count: 3)
}
}
private struct CardLayoutPickerView: View {
let selectedLayout: CardLayoutStyle
let onSelect: (CardLayoutStyle) -> Void
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
Text("Images & layout")
.font(.headline)
.bold()
.foregroundStyle(Color.Text.primary)
Picker(String.localized("Layout"), selection: Binding(
get: { selectedLayout },
set: { onSelect($0) }
)) {
ForEach(CardLayoutStyle.allCases) { layout in
Text(layout.displayName)
.tag(layout)
}
}
.pickerStyle(.segmented)
Text("Change image layout")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
}
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
}
#Preview {
CustomizeCardView()
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
}

View File

@ -1,96 +0,0 @@
import SwiftUI
import Bedrock
struct HeroBannerView: View {
var body: some View {
VStack(spacing: Design.Spacing.medium) {
Text("The #1 Digital Business Card App")
.font(.title2)
.bold()
.foregroundStyle(Color.Text.primary)
.multilineTextAlignment(.center)
HStack(spacing: Design.Spacing.large) {
StatBadgeView(
title: String.localized("4.9"),
subtitle: String.localized("App Rating"),
systemImage: "star.fill",
badgeColor: Color.Badge.star
)
StatBadgeView(
title: String.localized("100k+"),
subtitle: String.localized("Reviews"),
systemImage: "person.3.fill",
badgeColor: Color.Badge.neutral
)
}
Text("Used by Industry Leaders")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
HStack(spacing: Design.Spacing.large) {
BrandChipView(label: "Google")
BrandChipView(label: "Tesla")
BrandChipView(label: "Citi")
}
}
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusMedium,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetSmall
)
}
}
private struct StatBadgeView: View {
let title: String
let subtitle: String
let systemImage: String
let badgeColor: Color
var body: some View {
VStack(spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: systemImage)
.font(.caption)
.foregroundStyle(Color.Text.primary)
Text(title)
.font(.headline)
.bold()
.foregroundStyle(Color.Text.primary)
}
Text(subtitle)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(badgeColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.accessibilityElement(children: .combine)
}
}
private struct BrandChipView: View {
let label: String
var body: some View {
Text(label)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.xSmall)
.background(Color.AppBackground.accent)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
}
#Preview {
HeroBannerView()
}

View File

@ -16,10 +16,6 @@ struct RootTabView: View {
ShareCardView() ShareCardView()
} }
Tab(String.localized("Customize"), systemImage: "slider.horizontal.3", value: AppTab.customize) {
CustomizeCardView()
}
Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) { Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) {
ContactsView() ContactsView()
} }

View File

@ -1,39 +0,0 @@
import SwiftUI
import Bedrock
struct WidgetsCalloutView: View {
var body: some View {
HStack(spacing: Design.Spacing.large) {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text("Share using widgets on your phone or watch")
.font(.headline)
.bold()
.foregroundStyle(Color.Text.primary)
Text("Add a QR widget so your card is always one tap away.")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
}
Spacer()
Image(systemName: "applewatch")
.font(.title)
.foregroundStyle(Color.Accent.red)
}
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusMedium,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetSmall
)
}
}
#Preview {
WidgetsCalloutView()
.padding()
.background(Color.AppBackground.base)
}

View File

@ -12,12 +12,15 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
### My Cards ### My Cards
- Create and browse multiple cards in a compact carousel view - **Full-screen card display**: Swipe between your business cards in a full-screen view
- 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 - Set a default card for sharing
- **Modern card design**: Banner with company logo, overlapping profile photo, clean contact rows - **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 - **Profile photos**: Add a photo from your library or use an icon
- **Company logos**: Upload a logo to display on your card's banner - **Company logos**: Upload a logo to display on your card's banner
- **Rich profiles**: First/middle/last name, pronouns, headline, bio, accreditations - **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
### Share ### Share
@ -27,16 +30,36 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
- **Track shares**: Record who received your card and when - **Track shares**: Record who received your card and when
- Placeholder actions for Apple Wallet and NFC (alerts included) - Placeholder actions for Apple Wallet and NFC (alerts included)
### Customize ### Card Editor
Access via the edit (pencil) icon in the My Cards tab:
- **Horizontal color picker**: Scrollable theme swatches for quick selection - **Horizontal color picker**: Scrollable theme swatches for quick selection
- Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet) - Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet)
- **Expandable name section**: First, middle, last, suffix, preferred name - **Expandable name section**: Prefix, first, middle, last, maiden name, suffix, preferred name, pronouns
- **Edit all card details**: Name, role, department, company, headline, email (with label), phone (with label & extension), website, location - **Edit all card details**: Name, role, department, company, headline, bio
- **Social media links**: LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub, Threads, Telegram - **Accreditations**: Tag-based input with inline editing
- **Payment links**: Venmo, Cash App
- **Custom links**: Add up to 2 custom URLs with titles #### Dynamic Contact Fields
- **Suggestion chips**: Quick-fill suggestions for social link titles
Add unlimited contact fields of any type, reorder by drag-and-drop:
- **Contact**: Email, phone, website, address
- **Social Media**: LinkedIn, X/Twitter, Instagram, Facebook, TikTok, Threads, Bluesky, Mastodon, Reddit, Twitch, YouTube, Snapchat, Pinterest
- **Developer**: GitHub, GitLab, Stack Overflow, CodePen
- **Messaging**: Telegram, WhatsApp, Discord, Slack, Matrix, Signal
- **Support & Funding**: Patreon, Ko-fi, Buy Me a Coffee, GitHub Sponsors
- **Payment**: Venmo, Cash App, PayPal, Zelle
- **Scheduling**: Calendly
- **Other**: Custom links with any URL
Each field has:
- **Custom icons**: Brand-colored icons from asset catalog (not generic SF Symbols)
- **Title/Label**: Add context like "Work", "Personal", or custom call-to-action
- **Deep linking**: Opens native apps when available (X, Instagram, etc.)
- **Drag-to-reorder**: Long press and drag to change order
- **Suggestion chips**: Quick-fill suggestions for field titles
- **Live preview**: "Preview card" button to see changes before saving - **Live preview**: "Preview card" button to see changes before saving
- **Delete cards** you no longer need - **Delete cards** you no longer need
@ -103,16 +126,22 @@ App-specific extensions are in `Design/DesignConstants.swift`.
``` ```
BusinessCard/ BusinessCard/
├── Assets.xcassets/
│ └── SocialSymbols/ # Custom brand icons (LinkedIn, X, Instagram, etc.)
├── Design/ # Design constants (extends Bedrock) ├── Design/ # Design constants (extends Bedrock)
├── Localization/ # String helpers ├── Localization/ # String helpers
├── Models/ # SwiftData models (BusinessCard, Contact) ├── Models/
│ ├── BusinessCard.swift # Main card model
│ ├── Contact.swift # Received contacts
│ ├── ContactField.swift # Dynamic contact fields (SwiftData)
│ └── ContactFieldType.swift # Field type definitions with icons & URLs
├── Protocols/ # Protocol definitions ├── Protocols/ # Protocol definitions
├── Resources/ # String Catalogs (.xcstrings) ├── Resources/ # String Catalogs (.xcstrings)
├── Services/ # Share link service, watch sync ├── Services/ # Share link service, watch sync
├── State/ # Observable stores (CardStore, ContactsStore) ├── State/ # Observable stores (CardStore, ContactsStore)
└── Views/ └── Views/
├── Components/ # Reusable UI components (AvatarBadgeView, etc.) ├── Components/ # Reusable UI (ContactFieldPickerView, AddedContactFieldsView, etc.)
├── Sheets/ # Modal sheets (RecordContactSheet, etc.) ├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.)
└── [Feature].swift # Feature screens └── [Feature].swift # Feature screens
BusinessCardWatch/ # watchOS app target BusinessCardWatch/ # watchOS app target

View File

@ -6,6 +6,39 @@ This document tracks planned features and their implementation status.
### High Priority (Core User Value) ### High Priority (Core User Value)
- [x] **Dynamic contact fields system** - Unlimited fields with custom ordering
- New `ContactField` SwiftData model for flexible field storage
- `ContactFieldType` struct with 30+ field types across 8 categories
- Drag-to-reorder support with haptic feedback
- Sheet-based field editor with type-specific keyboards and placeholders
- Multiple fields of the same type allowed (e.g., multiple emails)
- [x] **Custom social icons** - Brand-colored icons from asset catalog
- Custom `.symbolset` assets for all major platforms
- LinkedIn, X/Twitter, Instagram, Facebook, TikTok, Threads
- Bluesky, Mastodon, Reddit, Twitch, YouTube, Discord
- GitHub, Telegram, Slack, Matrix, Patreon, Ko-fi
- Proper `Image()` vs `Image(systemName:)` handling via `iconImage()` helper
- [x] **Extended name fields** - Prefix, maiden name, pronouns
- Expandable name section in editor
- Prefix (Dr., Mr., Mrs., etc.)
- Maiden name with parentheses formatting in display name
- Preferred name with quotes formatting
- Pronouns displayed next to name on card
- [x] **Accreditations as tags** - Tag bubble UI with inline editing
- Horizontal ScrollView of tag chips
- Tap to edit, check to save, x to delete
- Comma-separated storage for vCard compatibility
- [x] **Clickable contact fields** - Deep linking to apps and actions
- Email → mailto:
- Phone → tel:
- Social links → app deep links or web fallback
- Address → Maps
- Website → Safari
- [x] **More card fields** - Social media links, custom URLs, pronouns, bio - [x] **More card fields** - Social media links, custom URLs, pronouns, bio
- Added: pronouns, bio, LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub - Added: pronouns, bio, LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub
- Added: 2 custom link slots (title + URL) - Added: 2 custom link slots (title + URL)
@ -78,6 +111,9 @@ This document tracks planned features and their implementation status.
- [x] **Bedrock integration** - Design system, QR code generator - [x] **Bedrock integration** - Design system, QR code generator
- [x] **iOS-Watch sync** via App Groups - [x] **iOS-Watch sync** via App Groups
- [x] **Unit tests** for models, stores, and new features - [x] **Unit tests** for models, stores, and new features
- [x] **Fixed data persistence** - Removed aggressive store deletion on startup
- [x] **Custom symbol assets** - Brand icons in asset catalog with proper rendering
- [x] **Drag-and-drop reordering** - Using `draggable` and `dropDestination` modifiers
### Planned ### Planned
@ -85,6 +121,7 @@ This document tracks planned features and their implementation status.
- [ ] **Spotlight indexing** - Search cards from iOS search - [ ] **Spotlight indexing** - Search cards from iOS search
- [ ] **Siri shortcuts** - "Share my work card" - [ ] **Siri shortcuts** - "Share my work card"
- [ ] **App Intents** - iOS 17+ action button support - [ ] **App Intents** - iOS 17+ action button support
- [ ] **Migrate legacy properties** - Move all cards to new ContactField array
--- ---
@ -93,7 +130,8 @@ This document tracks planned features and their implementation status.
- Features marked with 🔲 are planned but not yet implemented - Features marked with 🔲 are planned but not yet implemented
- Features requiring backend are deferred until infrastructure is available - Features requiring backend are deferred until infrastructure is available
- Priority may shift based on user feedback - Priority may shift based on user feedback
- Legacy contact properties (email, phone, linkedIn, etc.) maintained for backward compatibility
--- ---
*Last updated: January 2026* *Last updated: January 8, 2026*

View File

@ -48,11 +48,29 @@ App-specific extensions are in `Design/DesignConstants.swift`:
### Models ### Models
- `Models/BusinessCard.swift` — SwiftData model with: - `Models/BusinessCard.swift` — SwiftData model with:
- Basic fields: name, role, company, email, phone, website, location - Name fields: prefix, firstName, middleName, lastName, maidenName, suffix, preferredName
- Rich fields: pronouns, bio, social links (LinkedIn, Twitter, Instagram, etc.) - Basic fields: role, company, email, phone, website, location
- Custom links: 2 slots for custom URLs - Rich fields: pronouns, bio, headline, accreditations (comma-separated tags)
- Photo: `photoData` stored with `@Attribute(.externalStorage)` - **Dynamic contact fields**: `@Relationship` to array of `ContactField` objects
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks` - Legacy fields: social links (LinkedIn, Twitter, etc.) for backward compatibility
- Photo: `photoData` and `logoData` stored with `@Attribute(.externalStorage)`
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks`, `orderedContactFields`
- Helper methods: `addContactField`, `removeContactField`, `reorderContactFields`
- `Models/ContactField.swift` — SwiftData model for dynamic contact fields:
- Properties: `typeId`, `value`, `title`, `orderIndex`
- Relationship: `card` (inverse to BusinessCard)
- Computed: `fieldType`, `displayName`, `iconImage()`, `iconColor`, `buildURL()`
- Supports multiple fields of same type, drag-to-reorder
- `Models/ContactFieldType.swift` — Struct defining 30+ field types:
- Categories: contact, social, developer, messaging, payment, creator, scheduling, other
- Properties: `id`, `displayName`, `systemImage`, `isCustomSymbol`, `iconColor`
- Properties: `valueLabel`, `valuePlaceholder`, `titleSuggestions`, `keyboardType`
- Method: `iconImage()` — returns correct Image (asset vs SF Symbol)
- Method: `urlBuilder` — closure to build deep link URLs
- Static instances: `.email`, `.phone`, `.linkedIn`, `.twitter`, `.instagram`, etc.
- Uses custom assets from `Assets.xcassets/SocialSymbols/`
- `Models/Contact.swift` — SwiftData model with: - `Models/Contact.swift` — SwiftData model with:
- Basic fields: name, role, company - Basic fields: name, role, company
@ -86,10 +104,9 @@ App-specific extensions are in `Design/DesignConstants.swift`:
### Views ### Views
Main screens: Main screens:
- `Views/RootTabView.swift` — tabbed shell - `Views/RootTabView.swift` — tabbed shell (4 tabs: My Cards, Share, Contacts, Widgets)
- `Views/CardsHomeView.swift`hero + card carousel - `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
- `Views/CustomizeCardView.swift` — theme/layout controls
- `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
@ -105,16 +122,31 @@ Reusable components (in `Views/Components/`):
- `IconRowView.swift` — icon + text row for details - `IconRowView.swift` — icon + text row for details
- `LabelBadgeView.swift` — small badge labels - `LabelBadgeView.swift` — small badge labels
- `ActionRowView.swift` — generic action row with chevron - `ActionRowView.swift` — generic action row with chevron
- `ContactFieldPickerView.swift` — grid picker for selecting contact field types
- `ContactFieldsManagerView.swift` — orchestrates picker + added fields list
- `AddedContactFieldsView.swift` — displays added fields with drag-to-reorder
Sheets (in `Views/Sheets/`): Sheets (in `Views/Sheets/`):
- `RecordContactSheet.swift` — track share recipient - `RecordContactSheet.swift` — track share recipient
- `ContactFieldEditorSheet.swift` — add/edit contact field with type-specific UI
Small utilities: Small utilities:
- `Views/EmptyStateView.swift` — empty state placeholder - `Views/EmptyStateView.swift` — empty state placeholder
- `Views/PrimaryActionButton.swift` — styled action button - `Views/PrimaryActionButton.swift` — styled action button
- `Views/HeroBannerView.swift` — home page banner
- `Views/CardCarouselView.swift` — card scroll carousel ### Assets
- `Views/WidgetsCalloutView.swift` — widget promotion callout
- `Assets.xcassets/SocialSymbols/` — custom brand icons as `.symbolset` files:
- Social: linkedin, x-twitter, instagram, facebook, tiktok, threads, bluesky, mastodon, reddit, twitch
- Developer: github.fill
- Messaging: telegram, discord.fill, slack, matrix
- Creator: patreon.fill, ko-fi
**Icon Rendering:**
- Custom symbols use `Image("symbolName")` (asset catalog)
- SF Symbols use `Image(systemName: "symbolName")`
- `ContactFieldType.isCustomSymbol` flag determines which to use
- `ContactFieldType.iconImage()` returns the correct Image type
### Design + Localization ### Design + Localization
@ -141,13 +173,16 @@ Small utilities:
### Current File Sizes ### Current File Sizes
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| CardEditorView | ~420 | Complex form, acceptable | | ContactFieldType | ~650 | 30+ field definitions, acceptable |
| CardEditorView | ~520 | Complex form + field manager, acceptable |
| BusinessCardView | ~320 | Clickable fields + legacy fallback, acceptable |
| QRScannerView | ~310 | Camera + parsing, acceptable | | QRScannerView | ~310 | Camera + parsing, acceptable |
| BusinessCardView | ~245 | Multiple layouts, acceptable |
| ShareCardView | ~235 | Good | | ShareCardView | ~235 | Good |
| ContactDetailView | ~235 | Good | | ContactDetailView | ~235 | Good |
| ContactsView | ~220 | Good | | ContactsView | ~220 | Good |
| CustomizeCardView | ~170 | Good | | CardsHomeView | ~150 | Full-screen swipeable cards, good |
| AddedContactFieldsView | ~135 | Drag-to-reorder, good |
| ContactFieldPickerView | ~100 | Grid picker, good |
| All others | <110 | Good | | All others | <110 | Good |
## Localization ## Localization