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

This commit is contained in:
Matt Bruce 2026-01-10 14:11:33 -06:00
parent a903aa7283
commit af64f0a7d9
16 changed files with 104 additions and 209 deletions

View File

@ -5,7 +5,6 @@ import SwiftUI
@Model @Model
final class BusinessCard { final class BusinessCard {
var id: UUID var id: UUID
var displayName: String
var role: String var role: String
var company: String var company: String
var label: String var label: String
@ -46,7 +45,6 @@ final class BusinessCard {
init( init(
id: UUID = UUID(), id: UUID = UUID(),
displayName: String = "",
role: String = "", role: String = "",
company: String = "", company: String = "",
label: String = "Work", label: String = "Work",
@ -74,7 +72,6 @@ final class BusinessCard {
logoData: Data? = nil logoData: Data? = nil
) { ) {
self.id = id self.id = id
self.displayName = displayName
self.role = role self.role = role
self.company = company self.company = company
self.label = label self.label = label
@ -197,13 +194,12 @@ final class BusinessCard {
} }
} }
/// Computed display name with special formatting: // MARK: - Name Properties (Single Source of Truth)
/// - Preferred name in quotes: "Bubba"
/// - Maiden name in parentheses: (Hackney)
/// - Pronouns in parentheses: (He/Him)
var computedDisplayName: String {
if !displayName.isEmpty { return displayName }
/// The full formatted name - THIS IS THE SINGLE SOURCE OF TRUTH for display.
/// Always computed from individual name fields.
/// Includes special formatting: preferred name in quotes, maiden name and pronouns in parentheses.
var fullName: String {
var parts: [String] = [] var parts: [String] = []
if !prefix.isEmpty { parts.append(prefix) } if !prefix.isEmpty { parts.append(prefix) }
@ -218,18 +214,12 @@ final class BusinessCard {
return parts.joined(separator: " ") return parts.joined(separator: " ")
} }
/// Returns the simple name for display (without formatting) - used for vCard /// Plain name for vCard export (no quotes or parentheses formatting).
var simpleName: String { /// Used when generating contact cards for sharing.
if !displayName.isEmpty { return displayName } var vCardName: String {
let parts = [prefix, firstName, middleName, lastName, suffix].filter { !$0.isEmpty } [prefix, firstName, middleName, lastName, suffix]
return parts.joined(separator: " ") .filter { !$0.isEmpty }
} .joined(separator: " ")
/// Returns the name to display (preferredName or first/last name)
var effectiveDisplayName: String {
if !preferredName.isEmpty { return preferredName }
let parts = [firstName, lastName].filter { !$0.isEmpty }
return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ")
} }
@MainActor @MainActor
@ -246,7 +236,7 @@ final class BusinessCard {
lines.append("N:\(structuredName)") lines.append("N:\(structuredName)")
// FN: Formatted name // FN: Formatted name
let formattedName = simpleName.isEmpty ? displayName : simpleName let formattedName = vCardName
lines.append("FN:\(escapeVCardValue(formattedName))") lines.append("FN:\(escapeVCardValue(formattedName))")
// NICKNAME: Preferred name // NICKNAME: Preferred name
@ -378,7 +368,7 @@ final class BusinessCard {
lines.append("N:\(structuredName)") lines.append("N:\(structuredName)")
// FN: Formatted name // FN: Formatted name
let formattedName = simpleName.isEmpty ? displayName : simpleName let formattedName = vCardName
lines.append("FN:\(escapeVCardValue(formattedName))") lines.append("FN:\(escapeVCardValue(formattedName))")
// PHOTO: Embedded profile photo as base64-encoded JPEG // PHOTO: Embedded profile photo as base64-encoded JPEG
@ -513,7 +503,6 @@ extension BusinessCard {
// Sample 1: Property Developer - Uses coverWithAvatarAndLogo layout // Sample 1: Property Developer - Uses coverWithAvatarAndLogo layout
// Best when: Has cover, logo, and profile - logo centered on cover // Best when: Has cover, logo, and profile - logo centered on cover
let sample1 = BusinessCard( let sample1 = BusinessCard(
displayName: "Daniel Sullivan",
role: "Property Developer", role: "Property Developer",
company: "WR Construction", company: "WR Construction",
label: "Work", label: "Work",
@ -522,6 +511,8 @@ extension BusinessCard {
layoutStyleRawValue: "split", layoutStyleRawValue: "split",
headerLayoutRawValue: "coverWithAvatarAndLogo", headerLayoutRawValue: "coverWithAvatarAndLogo",
avatarSystemName: "person.crop.circle", avatarSystemName: "person.crop.circle",
firstName: "Daniel",
lastName: "Sullivan",
pronouns: "he/him", pronouns: "he/him",
bio: "Building the future of Dallas real estate" bio: "Building the future of Dallas real estate"
) )
@ -535,7 +526,6 @@ extension BusinessCard {
// Sample 2: Creative Lead - Uses logoBanner layout // Sample 2: Creative Lead - Uses logoBanner layout
// Best when: Strong logo, no cover needed // Best when: Strong logo, no cover needed
let sample2 = BusinessCard( let sample2 = BusinessCard(
displayName: "Maya Chen",
role: "Creative Lead", role: "Creative Lead",
company: "Signal Studio", company: "Signal Studio",
label: "Creative", label: "Creative",
@ -544,6 +534,8 @@ extension BusinessCard {
layoutStyleRawValue: "stacked", layoutStyleRawValue: "stacked",
headerLayoutRawValue: "logoBanner", headerLayoutRawValue: "logoBanner",
avatarSystemName: "sparkles", avatarSystemName: "sparkles",
firstName: "Maya",
lastName: "Chen",
pronouns: "she/her", pronouns: "she/her",
bio: "Designing experiences that matter" bio: "Designing experiences that matter"
) )
@ -558,7 +550,6 @@ extension BusinessCard {
// Sample 3: DJ - Uses profileBanner layout // Sample 3: DJ - Uses profileBanner layout
// Best when: Strong profile photo, personal brand // Best when: Strong profile photo, personal brand
let sample3 = BusinessCard( let sample3 = BusinessCard(
displayName: "DJ Michaels",
role: "DJ", role: "DJ",
company: "Live Sessions", company: "Live Sessions",
label: "Music", label: "Music",
@ -567,6 +558,8 @@ extension BusinessCard {
layoutStyleRawValue: "photo", layoutStyleRawValue: "photo",
headerLayoutRawValue: "profileBanner", headerLayoutRawValue: "profileBanner",
avatarSystemName: "music.mic", avatarSystemName: "music.mic",
firstName: "DJ",
lastName: "Michaels",
bio: "Bringing the beats to your events" bio: "Bringing the beats to your events"
) )
context.insert(sample3) context.insert(sample3)

View File

@ -6,21 +6,21 @@ struct ShareLinkService: ShareLinkProviding {
} }
func smsURL(for card: BusinessCard) -> URL { func smsURL(for card: BusinessCard) -> URL {
let body = String.localized("ShareTextBody", card.displayName, card.shareURL.absoluteString) let body = String.localized("ShareTextBody", card.fullName, card.shareURL.absoluteString)
let query = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let query = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return URL(string: "sms:&body=\(query)") ?? card.shareURL return URL(string: "sms:&body=\(query)") ?? card.shareURL
} }
func emailURL(for card: BusinessCard) -> URL { func emailURL(for card: BusinessCard) -> URL {
let subject = String.localized("ShareEmailSubject", card.displayName) let subject = String.localized("ShareEmailSubject", card.fullName)
let body = String.localized("ShareEmailBody", card.displayName, card.shareURL.absoluteString) let body = String.localized("ShareEmailBody", card.fullName, card.shareURL.absoluteString)
let subjectQuery = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let subjectQuery = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
let bodyQuery = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let bodyQuery = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return URL(string: "mailto:?subject=\(subjectQuery)&body=\(bodyQuery)") ?? card.shareURL return URL(string: "mailto:?subject=\(subjectQuery)&body=\(bodyQuery)") ?? card.shareURL
} }
func whatsappURL(for card: BusinessCard) -> URL { func whatsappURL(for card: BusinessCard) -> URL {
let message = String.localized("ShareWhatsAppBody", card.displayName, card.shareURL.absoluteString) let message = String.localized("ShareWhatsAppBody", card.fullName, card.shareURL.absoluteString)
let query = message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let query = message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return URL(string: "https://wa.me/?text=\(query)") ?? card.shareURL return URL(string: "https://wa.me/?text=\(query)") ?? card.shareURL
} }

View File

@ -11,7 +11,7 @@ struct VCardFileService {
let payload = card.vCardFilePayload let payload = card.vCardFilePayload
// Create a sanitized filename from the card's name // Create a sanitized filename from the card's name
let sanitizedName = sanitizeFilename(card.simpleName.isEmpty ? card.displayName : card.simpleName) let sanitizedName = sanitizeFilename(card.vCardName)
let filename = sanitizedName.isEmpty ? "contact" : sanitizedName let filename = sanitizedName.isEmpty ? "contact" : sanitizedName
// Write to temp directory with .vcf extension // Write to temp directory with .vcf extension

View File

@ -131,9 +131,9 @@ final class WatchConnectivityService: NSObject {
let addressValue = card.firstContactField(ofType: "address")?.value ?? "" let addressValue = card.firstContactField(ofType: "address")?.value ?? ""
let location = PostalAddress.decode(from: addressValue)?.singleLineString ?? addressValue let location = PostalAddress.decode(from: addressValue)?.singleLineString ?? addressValue
// Build vCard payload for QR code generation // Build vCard payload for QR code generation (use vCardName for compatibility)
let vCardPayload = buildVCardPayload( let vCardPayload = buildVCardPayload(
displayName: card.displayName, displayName: card.vCardName,
company: card.company, company: card.company,
role: card.role, role: card.role,
phone: phone, phone: phone,
@ -149,9 +149,13 @@ final class WatchConnectivityService: NSObject {
// Generate QR code image data on iOS (CoreImage not available on watchOS) // Generate QR code image data on iOS (CoreImage not available on watchOS)
let qrImageData = generateQRCodePNGData(from: vCardPayload) let qrImageData = generateQRCodePNGData(from: vCardPayload)
// Use fullName - the single source of truth for display names
let syncDisplayName = card.fullName
Design.debugLog("WatchConnectivity: Syncing card '\(syncDisplayName)'")
return SyncableCard( return SyncableCard(
id: card.id, id: card.id,
displayName: card.displayName, fullName: syncDisplayName,
role: card.role, role: card.role,
company: card.company, company: card.company,
email: email, email: email,
@ -269,7 +273,7 @@ extension WatchConnectivityService: WCSessionDelegate {
/// A simplified card structure that can be shared between iOS and watchOS /// A simplified card structure that can be shared between iOS and watchOS
struct SyncableCard: Codable, Identifiable { struct SyncableCard: Codable, Identifiable {
let id: UUID let id: UUID
var displayName: String var fullName: String
var role: String var role: String
var company: String var company: String
var email: String var email: String

View File

@ -42,7 +42,7 @@ final class CardStore: BusinessCardProviding {
} }
func updateCard(_ card: BusinessCard) { func updateCard(_ card: BusinessCard) {
Design.debugLog("CardStore: updateCard called for: \(card.displayName)") Design.debugLog("CardStore: updateCard - fullName: '\(card.fullName)'")
card.updatedAt = .now card.updatedAt = .now
saveContext() saveContext()
fetchCards() fetchCards()

View File

@ -28,7 +28,7 @@ struct BusinessCardView: View {
) )
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(String.localized("Business card")) .accessibilityLabel(String.localized("Business card"))
.accessibilityValue("\(card.effectiveDisplayName), \(card.role), \(card.company)") .accessibilityValue("\(card.fullName), \(card.role), \(card.company)")
} }
} }
@ -172,7 +172,7 @@ private struct CardContentView: View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) { HStack(spacing: Design.Spacing.xSmall) {
Text(card.effectiveDisplayName) Text(card.fullName)
.font(.title2) .font(.title2)
.bold() .bold()
.foregroundStyle(textColor) .foregroundStyle(textColor)
@ -390,11 +390,12 @@ private struct ContactFieldRowView: View {
#Preview("Profile Banner") { #Preview("Profile Banner") {
@Previewable @State var card = BusinessCard( @Previewable @State var card = BusinessCard(
displayName: "Matt Bruce",
role: "Lead iOS Developer", role: "Lead iOS Developer",
company: "Toyota", company: "Toyota",
themeName: "Coral", themeName: "Coral",
headerLayoutRawValue: "profileBanner" headerLayoutRawValue: "profileBanner",
firstName: "Matt",
lastName: "Bruce"
) )
BusinessCardView(card: card) BusinessCardView(card: card)
@ -404,11 +405,12 @@ private struct ContactFieldRowView: View {
#Preview("Cover + Avatar + Logo") { #Preview("Cover + Avatar + Logo") {
@Previewable @State var card = BusinessCard( @Previewable @State var card = BusinessCard(
displayName: "Matt Bruce",
role: "Lead iOS Developer", role: "Lead iOS Developer",
company: "Toyota", company: "Toyota",
themeName: "Violet", themeName: "Violet",
headerLayoutRawValue: "coverWithAvatarAndLogo" headerLayoutRawValue: "coverWithAvatarAndLogo",
firstName: "Matt",
lastName: "Bruce"
) )
BusinessCardView(card: card) BusinessCardView(card: card)

View File

@ -11,7 +11,6 @@ struct CardEditorView: View {
let onSave: (BusinessCard) -> Void let onSave: (BusinessCard) -> Void
// Name fields // Name fields
@State private var displayName = ""
@State private var prefix = "" @State private var prefix = ""
@State private var firstName = "" @State private var firstName = ""
@State private var middleName = "" @State private var middleName = ""
@ -86,20 +85,12 @@ struct CardEditorView: View {
private var isEditing: Bool { card != nil } private var isEditing: Bool { card != nil }
private var isFormValid: Bool { private var isFormValid: Bool {
!effectiveDisplayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
/// Simple name for validation and storage (without quotes/parentheses formatting) /// The full formatted name - matches BusinessCard.fullName logic.
private var effectiveDisplayName: String { /// Single source of truth for name display in the editor.
if !displayName.isEmpty { return displayName } private var fullName: String {
let parts = [prefix, firstName, middleName, lastName, suffix].filter { !$0.isEmpty }
return parts.joined(separator: " ")
}
/// Formatted name for display with special formatting
private var formattedDisplayName: String {
if !displayName.isEmpty { return displayName }
var parts: [String] = [] var parts: [String] = []
if !prefix.isEmpty { parts.append(prefix) } if !prefix.isEmpty { parts.append(prefix) }
@ -158,8 +149,8 @@ struct CardEditorView: View {
withAnimation { showNameDetails.toggle() } withAnimation { showNameDetails.toggle() }
} label: { } label: {
HStack { HStack {
Text(formattedDisplayName.isEmpty ? "Full Name" : formattedDisplayName) Text(fullName.isEmpty ? "Full Name" : fullName)
.foregroundStyle(formattedDisplayName.isEmpty ? Color.secondary : Color.primary) .foregroundStyle(fullName.isEmpty ? Color.secondary : Color.primary)
Spacer() Spacer()
Image(systemName: showNameDetails ? "chevron.up" : "chevron.down") Image(systemName: showNameDetails ? "chevron.up" : "chevron.down")
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
@ -316,7 +307,7 @@ struct CardEditorView: View {
logoData: logoData, logoData: logoData,
avatarSystemName: avatarSystemName, avatarSystemName: avatarSystemName,
theme: selectedTheme, theme: selectedTheme,
displayName: effectiveDisplayName, displayName: fullName,
role: role, role: role,
company: company company: company
) )
@ -1103,7 +1094,6 @@ private struct CardPreviewSheet: View {
private extension CardEditorView { private extension CardEditorView {
func loadCardData() { func loadCardData() {
guard let card else { return } guard let card else { return }
displayName = card.displayName
prefix = card.prefix prefix = card.prefix
firstName = card.firstName firstName = card.firstName
middleName = card.middleName middleName = card.middleName
@ -1182,7 +1172,6 @@ private extension CardEditorView {
} }
func updateCard(_ card: BusinessCard) { func updateCard(_ card: BusinessCard) {
card.displayName = displayName.isEmpty ? effectiveDisplayName : displayName
card.prefix = prefix card.prefix = prefix
card.firstName = firstName card.firstName = firstName
card.middleName = middleName card.middleName = middleName
@ -1212,7 +1201,6 @@ private extension CardEditorView {
func createCard() -> BusinessCard { func createCard() -> BusinessCard {
let newCard = BusinessCard( let newCard = BusinessCard(
displayName: displayName.isEmpty ? effectiveDisplayName : displayName,
role: role, role: role,
company: company, company: company,
label: label, label: label,
@ -1246,7 +1234,6 @@ private extension CardEditorView {
func buildPreviewCard() -> BusinessCard { func buildPreviewCard() -> BusinessCard {
let previewCard = BusinessCard( let previewCard = BusinessCard(
displayName: displayName.isEmpty ? effectiveDisplayName : displayName,
role: role, role: role,
company: company, company: company,
label: label, label: label,

View File

@ -67,7 +67,7 @@ struct ShareCardView: View {
.ignoresSafeArea() .ignoresSafeArea()
} }
.sheet(item: $mailComposeURL) { url in .sheet(item: $mailComposeURL) { url in
MailComposeView(vCardURL: url, cardName: appState.cardStore.selectedCard?.simpleName ?? "Contact") MailComposeView(vCardURL: url, cardName: appState.cardStore.selectedCard?.vCardName ?? "Contact")
.ignoresSafeArea() .ignoresSafeArea()
} }
.onAppear { .onAppear {

View File

@ -53,7 +53,7 @@ private struct PhoneWidgetPreview: View {
.frame(width: Design.CardSize.widgetPhoneHeight, height: Design.CardSize.widgetPhoneHeight) .frame(width: Design.CardSize.widgetPhoneHeight, height: Design.CardSize.widgetPhoneHeight)
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(card.displayName) Text(card.fullName)
.font(.headline) .font(.headline)
.foregroundStyle(Color.Text.primary) .foregroundStyle(Color.Text.primary)
Text(card.role) Text(card.role)

View File

@ -95,8 +95,8 @@ struct BusinessCardTests {
let context = container.mainContext let context = container.mainContext
// Insert cards directly instead of using samples (which might trigger other logic) // Insert cards directly instead of using samples (which might trigger other logic)
let card1 = BusinessCard(displayName: "Card One", role: "Role", company: "Company", isDefault: true) let card1 = BusinessCard(role: "Role", company: "Company", isDefault: true, firstName: "Card", lastName: "One")
let card2 = BusinessCard(displayName: "Card Two", role: "Role", company: "Company", isDefault: false) let card2 = BusinessCard(role: "Role", company: "Company", isDefault: false, firstName: "Card", lastName: "Two")
context.insert(card1) context.insert(card1)
context.insert(card2) context.insert(card2)
try context.save() try context.save()
@ -143,9 +143,10 @@ struct BusinessCardTests {
let initialCount = store.cards.count let initialCount = store.cards.count
let newCard = BusinessCard( let newCard = BusinessCard(
displayName: "New User",
role: "Manager", role: "Manager",
company: "New Corp" company: "New Corp",
firstName: "New",
lastName: "User"
) )
store.addCard(newCard) store.addCard(newCard)
@ -156,8 +157,8 @@ struct BusinessCardTests {
let container = try makeTestContainer() let container = try makeTestContainer()
let context = container.mainContext let context = container.mainContext
let card1 = BusinessCard(displayName: "Card One", role: "Role", company: "Company") let card1 = BusinessCard(role: "Role", company: "Company", firstName: "Card", lastName: "One")
let card2 = BusinessCard(displayName: "Card Two", role: "Role", company: "Company") let card2 = BusinessCard(role: "Role", company: "Company", firstName: "Card", lastName: "Two")
context.insert(card1) context.insert(card1)
context.insert(card2) context.insert(card2)
try context.save() try context.save()
@ -185,12 +186,13 @@ struct BusinessCardTests {
let store = CardStore(modelContext: context) let store = CardStore(modelContext: context)
card.displayName = "Updated Name" card.firstName = "Updated"
card.lastName = "Name"
card.role = "Updated Role" card.role = "Updated Role"
store.updateCard(card) store.updateCard(card)
let updatedCard = store.cards.first(where: { $0.id == card.id }) let updatedCard = store.cards.first(where: { $0.id == card.id })
#expect(updatedCard?.displayName == "Updated Name") #expect(updatedCard?.fullName == "Updated Name")
#expect(updatedCard?.role == "Updated Role") #expect(updatedCard?.role == "Updated Role")
} }

View File

@ -9,6 +9,7 @@ enum WatchDesign {
} }
enum Spacing { enum Spacing {
static let extraSmall: CGFloat = 4
static let small: CGFloat = 6 static let small: CGFloat = 6
static let medium: CGFloat = 10 static let medium: CGFloat = 10
static let large: CGFloat = 16 static let large: CGFloat = 16

View File

@ -1,10 +1,10 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
/// A simplified card structure synced from the iOS app via App Group UserDefaults /// A simplified card structure synced from the iOS app via WatchConnectivity
struct WatchCard: Codable, Identifiable, Hashable { struct WatchCard: Codable, Identifiable, Hashable {
let id: UUID let id: UUID
var displayName: String var fullName: String
var role: String var role: String
var company: String var company: String
var email: String var email: String

View File

@ -14,6 +14,7 @@
} }
}, },
"Choose default" : { "Choose default" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -36,6 +37,7 @@
} }
}, },
"Default Card" : { "Default Card" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -57,15 +59,12 @@
} }
} }
}, },
"Default card QR code" : {
"comment" : "An accessibility label describing a view that shows the QR code of a user's default watch card.",
"isCommentAutoGenerated" : true
},
"No Cards" : { "No Cards" : {
"comment" : "A message displayed when a user has no watch cards.", "comment" : "A message displayed when a user has no watch cards.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Not selected" : { "Not selected" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -91,7 +90,12 @@
"comment" : "A description of the action to create a watch card from the iPhone app.", "comment" : "A description of the action to create a watch card from the iPhone app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"QR code" : {
"comment" : "A label describing a view that displays a QR code.",
"isCommentAutoGenerated" : true
},
"Selected" : { "Selected" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {

View File

@ -49,7 +49,7 @@ final class WatchConnectivityService: NSObject {
let watchCards = syncableCards.map { syncable in let watchCards = syncableCards.map { syncable in
WatchCard( WatchCard(
id: syncable.id, id: syncable.id,
displayName: syncable.displayName, fullName: syncable.fullName,
role: syncable.role, role: syncable.role,
company: syncable.company, company: syncable.company,
email: syncable.email, email: syncable.email,
@ -108,7 +108,7 @@ extension WatchConnectivityService: WCSessionDelegate {
/// Syncable card structure matching iOS side (for decoding) /// Syncable card structure matching iOS side (for decoding)
private struct SyncableCard: Codable, Identifiable { private struct SyncableCard: Codable, Identifiable {
let id: UUID let id: UUID
var displayName: String var fullName: String
var role: String var role: String
var company: String var company: String
var email: String var email: String

View File

@ -4,56 +4,26 @@ import Observation
@Observable @Observable
@MainActor @MainActor
final class WatchCardStore { final class WatchCardStore {
private static let defaultCardIDKey = "WatchDefaultCardID"
private(set) var cards: [WatchCard] = [] private(set) var cards: [WatchCard] = []
var defaultCardID: UUID? {
didSet { /// The ID of the default card (synced from iPhone)
persistDefaultID() private(set) var defaultCardID: UUID?
}
}
init() { init() {
loadDefaultID()
WatchDesign.debugLog("WatchCardStore: Initialized, waiting for cards from iPhone") WatchDesign.debugLog("WatchCardStore: Initialized, waiting for cards from iPhone")
// Set up callback for when cards are received from iPhone // Set up callback for when cards are received from iPhone
WatchConnectivityService.shared.onCardsReceived = { [weak self] cards in WatchConnectivityService.shared.onCardsReceived = { [weak self] cards in
self?.updateCards(cards) self?.updateCards(cards)
} }
#if targetEnvironment(simulator)
// Load sample data on simulator since WatchConnectivity often doesn't work
if cards.isEmpty {
loadSimulatorSampleData()
}
#endif
} }
#if targetEnvironment(simulator) /// The default card to display (always synced from iPhone)
private func loadSimulatorSampleData() {
WatchDesign.debugLog("WatchCardStore: Loading simulator sample data")
cards = [
WatchCard(
id: UUID(),
displayName: "Test User",
role: "iOS Developer",
company: "Sample Corp",
email: "test@example.com",
phone: "+1 555-0123",
website: "example.com",
location: "San Francisco, CA",
isDefault: true,
qrCodeImageData: nil
)
]
defaultCardID = cards.first?.id
}
#endif
var defaultCard: WatchCard? { var defaultCard: WatchCard? {
guard let defaultCardID else { return cards.first(where: { $0.isDefault }) ?? cards.first } if let defaultCardID {
return cards.first(where: { $0.id == defaultCardID }) ?? cards.first(where: { $0.isDefault }) ?? cards.first return cards.first { $0.id == defaultCardID }
}
return cards.first { $0.isDefault } ?? cards.first
} }
/// Called by WatchConnectivityService when cards are received from iPhone /// Called by WatchConnectivityService when cards are received from iPhone
@ -61,26 +31,7 @@ final class WatchCardStore {
WatchDesign.debugLog("WatchCardStore: Received \(newCards.count) cards from iPhone") WatchDesign.debugLog("WatchCardStore: Received \(newCards.count) cards from iPhone")
cards = newCards cards = newCards
// Update default card ID if current selection is no longer valid // Always use the iPhone's default card - the watch no longer has its own picker
if let currentDefault = defaultCardID, !cards.contains(where: { $0.id == currentDefault }) { defaultCardID = cards.first { $0.isDefault }?.id ?? cards.first?.id
defaultCardID = cards.first(where: { $0.isDefault })?.id ?? cards.first?.id
} else if defaultCardID == nil {
defaultCardID = cards.first(where: { $0.isDefault })?.id ?? cards.first?.id
}
}
func setDefault(_ card: WatchCard) {
defaultCardID = card.id
}
private func persistDefaultID() {
UserDefaults.standard.set(defaultCardID?.uuidString ?? "", forKey: Self.defaultCardIDKey)
}
private func loadDefaultID() {
let storedValue = UserDefaults.standard.string(forKey: Self.defaultCardIDKey) ?? ""
if let id = UUID(uuidString: storedValue) {
defaultCardID = id
}
} }
} }

View File

@ -4,21 +4,11 @@ struct WatchContentView: View {
@Environment(WatchCardStore.self) private var cardStore @Environment(WatchCardStore.self) private var cardStore
var body: some View { var body: some View {
ScrollView {
VStack(spacing: WatchDesign.Spacing.large) {
if let card = cardStore.defaultCard { if let card = cardStore.defaultCard {
WatchQRCodeCardView(card: card) WatchQRCodeCardView(card: card)
} else if cardStore.cards.isEmpty { } else {
WatchEmptyStateView() WatchEmptyStateView()
} }
if !cardStore.cards.isEmpty {
WatchCardPickerView()
}
}
.padding(WatchDesign.Spacing.medium)
}
.background(Color.WatchPalette.background)
} }
} }
@ -46,11 +36,7 @@ private struct WatchQRCodeCardView: View {
let card: WatchCard let card: WatchCard
var body: some View { var body: some View {
VStack(spacing: WatchDesign.Spacing.small) { VStack(spacing: WatchDesign.Spacing.medium) {
Text("Default Card")
.font(.headline)
.foregroundStyle(Color.WatchPalette.text)
if let image = card.qrCodeImage { if let image = card.qrCodeImage {
image image
.resizable() .resizable()
@ -75,57 +61,22 @@ private struct WatchQRCodeCardView: View {
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large)) .clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
} }
Text(card.displayName) VStack(spacing: WatchDesign.Spacing.extraSmall) {
.font(.subheadline) Text(card.fullName)
.font(.headline)
.foregroundStyle(Color.WatchPalette.text) .foregroundStyle(Color.WatchPalette.text)
Text(card.role) Text(card.role)
.font(.caption) .font(.caption)
.foregroundStyle(Color.WatchPalette.muted) .foregroundStyle(Color.WatchPalette.muted)
} }
}
.padding(WatchDesign.Spacing.medium) .padding(WatchDesign.Spacing.medium)
.background(Color.WatchPalette.card) .frame(maxWidth: .infinity, maxHeight: .infinity)
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large)) .background(Color.WatchPalette.background)
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Default card QR code")) .accessibilityLabel(String(localized: "QR code"))
.accessibilityValue("\(card.displayName), \(card.role)") .accessibilityValue("\(card.fullName), \(card.role)")
}
}
private struct WatchCardPickerView: View {
@Environment(WatchCardStore.self) private var cardStore
var body: some View {
VStack(alignment: .leading, spacing: WatchDesign.Spacing.small) {
Text("Choose default")
.font(.headline)
.foregroundStyle(Color.WatchPalette.text)
ForEach(cardStore.cards) { card in
Button {
cardStore.setDefault(card)
} label: {
HStack {
Text(card.displayName)
.foregroundStyle(Color.WatchPalette.text)
Spacer()
if card.id == cardStore.defaultCardID {
Image(systemName: "checkmark")
.foregroundStyle(Color.WatchPalette.accent)
}
}
}
.buttonStyle(.plain)
.padding(.vertical, WatchDesign.Spacing.small)
.padding(.horizontal, WatchDesign.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.background(card.id == cardStore.defaultCardID ? Color.WatchPalette.accent.opacity(WatchDesign.Opacity.strong) : Color.WatchPalette.card)
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.medium))
.accessibilityValue(card.id == cardStore.defaultCardID ? String(localized: "Selected") : String(localized: "Not selected"))
}
}
.padding(WatchDesign.Spacing.medium)
.background(Color.WatchPalette.card.opacity(WatchDesign.Opacity.hint))
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
} }
} }