diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj
index cc73836..b4748a2 100644
--- a/BusinessCard.xcodeproj/project.pbxproj
+++ b/BusinessCard.xcodeproj/project.pbxproj
@@ -28,7 +28,6 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
- 60186E73BC8040538616865B /* BusinessCardWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCardWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
EA8379232F105F2600077F87 /* BusinessCard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCard.app; sourceTree = BUILT_PRODUCTS_DIR; };
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -72,13 +71,6 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
- 93EDDE26B3EB4E32AF5B58FC /* Frameworks */ = {
- isa = PBXFrameworksBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
EA8379202F105F2600077F87 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -126,7 +118,6 @@
isa = PBXGroup;
children = (
EA8379232F105F2600077F87 /* BusinessCard.app */,
- 60186E73BC8040538616865B /* BusinessCardWatch.app */,
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
@@ -137,25 +128,6 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
- D007169724A44109B518B9E6 /* BusinessCardWatch */ = {
- isa = PBXNativeTarget;
- buildConfigurationList = 3873468A4B2043BDAA689772 /* Build configuration list for PBXNativeTarget "BusinessCardWatch" */;
- buildPhases = (
- 7D1EBA94A23F41D5A441C5E4 /* Sources */,
- 93EDDE26B3EB4E32AF5B58FC /* Frameworks */,
- 9F6436BCE5F34967B6A4509D /* Resources */,
- );
- buildRules = (
- );
- dependencies = (
- );
- name = BusinessCardWatch;
- packageProductDependencies = (
- );
- productName = BusinessCardWatch;
- productReference = 60186E73BC8040538616865B /* BusinessCardWatch.app */;
- productType = "com.apple.product-type.application.watchapp2";
- };
EA8379222F105F2600077F87 /* BusinessCard */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA8379442F105F2800077F87 /* Build configuration list for PBXNativeTarget "BusinessCard" */;
@@ -257,9 +229,6 @@
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
- D007169724A44109B518B9E6 = {
- CreatedOnToolsVersion = 26.0;
- };
EA8379222F105F2600077F87 = {
CreatedOnToolsVersion = 26.0;
};
@@ -296,7 +265,6 @@
projectRoot = "";
targets = (
EA8379222F105F2600077F87 /* BusinessCard */,
- D007169724A44109B518B9E6 /* BusinessCardWatch */,
EA83792F2F105F2800077F87 /* BusinessCardTests */,
EA8379392F105F2800077F87 /* BusinessCardUITests */,
EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */,
@@ -305,13 +273,6 @@
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
- 9F6436BCE5F34967B6A4509D /* Resources */ = {
- isa = PBXResourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
EA8379212F105F2600077F87 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -343,13 +304,6 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
- 7D1EBA94A23F41D5A441C5E4 /* Sources */ = {
- isa = PBXSourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
EA83791F2F105F2600077F87 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -394,56 +348,6 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
- 2AA803F1BF6442BEBBEA0D74 /* Debug */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
- CODE_SIGN_ENTITLEMENTS = BusinessCardWatch/BusinessCardWatch.entitlements;
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = 6R7KLBPBLZ;
- ENABLE_PREVIEWS = YES;
- GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BusinessCardWatch;
- PRODUCT_NAME = "$(TARGET_NAME)";
- SDKROOT = watchos;
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SUPPORTED_PLATFORMS = watchos;
- SWIFT_APPROACHABLE_CONCURRENCY = YES;
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
- SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 6.2;
- TARGETED_DEVICE_FAMILY = 4;
- WATCHOS_DEPLOYMENT_TARGET = 12.0;
- };
- name = Debug;
- };
- B9B3B52E9CBF4C0BA6813348 /* Release */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
- CODE_SIGN_ENTITLEMENTS = BusinessCardWatch/BusinessCardWatch.entitlements;
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = 6R7KLBPBLZ;
- ENABLE_PREVIEWS = YES;
- GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BusinessCardWatch;
- PRODUCT_NAME = "$(TARGET_NAME)";
- SDKROOT = watchos;
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SUPPORTED_PLATFORMS = watchos;
- SWIFT_APPROACHABLE_CONCURRENCY = YES;
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
- SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 6.2;
- TARGETED_DEVICE_FAMILY = 4;
- WATCHOS_DEPLOYMENT_TARGET = 12.0;
- };
- name = Release;
- };
EA8379422F105F2800077F87 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -726,6 +630,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = "BusinessCardWatch Watch App/BusinessCardWatch.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
@@ -733,13 +638,13 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
- INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "";
+ INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.mbrucedogs.BusinessCard;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = .watchkitapp;
+ PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BusinessCard.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
@@ -759,6 +664,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = "BusinessCardWatch Watch App/BusinessCardWatch.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
@@ -766,13 +672,13 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
- INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "";
+ INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.mbrucedogs.BusinessCard;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = .watchkitapp;
+ PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BusinessCard.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
@@ -790,15 +696,6 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
- 3873468A4B2043BDAA689772 /* Build configuration list for PBXNativeTarget "BusinessCardWatch" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- 2AA803F1BF6442BEBBEA0D74 /* Debug */,
- B9B3B52E9CBF4C0BA6813348 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/BusinessCard.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/IDEFindNavigatorScopes.plist b/BusinessCard.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/IDEFindNavigatorScopes.plist
new file mode 100644
index 0000000..5dd5da8
--- /dev/null
+++ b/BusinessCard.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/IDEFindNavigatorScopes.plist
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
new file mode 100644
index 0000000..c68cbe9
--- /dev/null
+++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
@@ -0,0 +1,6 @@
+
+
+
diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
index b34a487..05429e1 100644
--- a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,17 +7,12 @@
BusinessCard.xcscheme_^#shared#^_
orderHint
- 1
+ 0
BusinessCardWatch Watch App.xcscheme_^#shared#^_
orderHint
- 3
-
- BusinessCardWatch.xcscheme_^#shared#^_
-
- orderHint
- 2
+ 1
diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift
index ed64e59..6e1f29e 100644
--- a/BusinessCard/BusinessCardApp.swift
+++ b/BusinessCard/BusinessCardApp.swift
@@ -65,6 +65,9 @@ struct BusinessCardApp: App {
self.modelContainer = container
let context = container.mainContext
self._appState = State(initialValue: AppState(modelContext: context))
+
+ // Activate WatchConnectivity session
+ _ = WatchConnectivityService.shared
}
var body: some Scene {
diff --git a/BusinessCard/Services/WatchConnectivityService.swift b/BusinessCard/Services/WatchConnectivityService.swift
new file mode 100644
index 0000000..0b88f5f
--- /dev/null
+++ b/BusinessCard/Services/WatchConnectivityService.swift
@@ -0,0 +1,258 @@
+import Foundation
+import WatchConnectivity
+import CoreImage
+import CoreImage.CIFilterBuiltins
+import UIKit
+import Bedrock
+
+/// Manages WatchConnectivity session and sends card data to Apple Watch
+@MainActor
+final class WatchConnectivityService: NSObject {
+ static let shared = WatchConnectivityService()
+
+ private var session: WCSession?
+ private var isActivated = false
+ private var pendingCards: [BusinessCard]?
+
+ private override init() {
+ super.init()
+ if WCSession.isSupported() {
+ session = WCSession.default
+ session?.delegate = self
+ session?.activate()
+ Design.debugLog("WatchConnectivity: Session activating...")
+ } else {
+ Design.debugLog("WatchConnectivity: Not supported on this device")
+ }
+ }
+
+ /// Syncs the given cards to the paired Apple Watch
+ func syncCards(_ cards: [BusinessCard]) {
+ // If not yet activated, store cards to send after activation
+ guard isActivated else {
+ Design.debugLog("WatchConnectivity: Session not ready, queuing \(cards.count) cards")
+ pendingCards = cards
+ return
+ }
+
+ guard let session = session else {
+ Design.debugLog("WatchConnectivity: No session available")
+ return
+ }
+
+ // In DEBUG, isWatchAppInstalled may be false even when running from Xcode
+ // So we only require isPaired and isReachable or just try to send anyway
+ #if DEBUG
+ guard session.isPaired else {
+ Design.debugLog("WatchConnectivity: Watch not paired")
+ return
+ }
+ Design.debugLog("WatchConnectivity: DEBUG mode - paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
+ #else
+ guard session.isPaired, session.isWatchAppInstalled else {
+ Design.debugLog("WatchConnectivity: Watch not available (paired: \(session.isPaired), installed: \(session.isWatchAppInstalled))")
+ return
+ }
+ #endif
+
+ sendCardsToWatch(cards)
+ }
+
+ private func sendCardsToWatch(_ cards: [BusinessCard]) {
+ Design.debugLog("WatchConnectivity: Syncing \(cards.count) cards to Watch")
+
+ let syncableCards = cards.map { card in
+ createSyncableCard(from: card)
+ }
+
+ do {
+ let encoded = try JSONEncoder().encode(syncableCards)
+ let userInfo: [String: Any] = ["cards": encoded]
+
+ // Use transferUserInfo instead of updateApplicationContext
+ // This queues the transfer and works even in debug mode
+ session?.transferUserInfo(userInfo)
+ Design.debugLog("WatchConnectivity: Queued \(encoded.count) bytes via transferUserInfo")
+ } catch {
+ Design.debugLog("WatchConnectivity: ERROR - Failed to encode: \(error)")
+ }
+ }
+
+ fileprivate func handleActivation() {
+ isActivated = true
+
+ // Send any pending cards that were queued before activation
+ if let cards = pendingCards {
+ Design.debugLog("WatchConnectivity: Sending \(cards.count) queued cards after activation")
+ pendingCards = nil
+ syncCards(cards)
+ }
+ }
+
+ private func createSyncableCard(from card: BusinessCard) -> SyncableCard {
+ // Get first email, phone, website, location from contact fields
+ let email = card.firstContactField(ofType: "email")?.value ?? ""
+ let phone = card.firstContactField(ofType: "phone")?.value ?? ""
+ let website = card.firstContactField(ofType: "website")?.value ?? ""
+ let linkedIn = card.firstContactField(ofType: "linkedIn")?.value ?? ""
+ let twitter = card.firstContactField(ofType: "twitter")?.value ?? ""
+ let instagram = card.firstContactField(ofType: "instagram")?.value ?? ""
+
+ // Format address for display (decode from stored JSON/legacy format)
+ let addressValue = card.firstContactField(ofType: "address")?.value ?? ""
+ let location = PostalAddress.decode(from: addressValue)?.singleLineString ?? addressValue
+
+ // Build vCard payload for QR code generation
+ let vCardPayload = buildVCardPayload(
+ displayName: card.displayName,
+ company: card.company,
+ role: card.role,
+ phone: phone,
+ email: email,
+ website: website,
+ location: location,
+ bio: card.bio,
+ linkedIn: linkedIn,
+ twitter: twitter,
+ instagram: instagram
+ )
+
+ // Generate QR code image data on iOS (CoreImage not available on watchOS)
+ let qrImageData = generateQRCodePNGData(from: vCardPayload)
+
+ return SyncableCard(
+ id: card.id,
+ displayName: card.displayName,
+ role: card.role,
+ company: card.company,
+ email: email,
+ phone: phone,
+ website: website,
+ location: location,
+ isDefault: card.isDefault,
+ pronouns: card.pronouns,
+ bio: card.bio,
+ linkedIn: linkedIn,
+ twitter: twitter,
+ instagram: instagram,
+ qrCodeImageData: qrImageData
+ )
+ }
+
+ private func buildVCardPayload(
+ displayName: String,
+ company: String,
+ role: String,
+ phone: String,
+ email: String,
+ website: String,
+ location: String,
+ bio: String,
+ linkedIn: String,
+ twitter: String,
+ instagram: String
+ ) -> String {
+ var lines = [
+ "BEGIN:VCARD",
+ "VERSION:3.0",
+ "FN:\(displayName)",
+ "ORG:\(company)",
+ "TITLE:\(role)"
+ ]
+
+ if !phone.isEmpty {
+ lines.append("TEL;TYPE=work:\(phone)")
+ }
+ if !email.isEmpty {
+ lines.append("EMAIL;TYPE=work:\(email)")
+ }
+ if !website.isEmpty {
+ lines.append("URL:\(website)")
+ }
+ if !location.isEmpty {
+ lines.append("ADR;TYPE=work:;;\(location)")
+ }
+ if !bio.isEmpty {
+ lines.append("NOTE:\(bio)")
+ }
+ if !linkedIn.isEmpty {
+ lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(linkedIn)")
+ }
+ if !twitter.isEmpty {
+ lines.append("X-SOCIALPROFILE;TYPE=twitter:\(twitter)")
+ }
+ if !instagram.isEmpty {
+ lines.append("X-SOCIALPROFILE;TYPE=instagram:\(instagram)")
+ }
+
+ lines.append("END:VCARD")
+ return lines.joined(separator: "\n")
+ }
+
+ private func generateQRCodePNGData(from payload: String) -> Data? {
+ let context = CIContext()
+ let data = Data(payload.utf8)
+ let filter = CIFilter.qrCodeGenerator()
+ filter.setValue(data, forKey: "inputMessage")
+ filter.correctionLevel = "M"
+
+ guard let outputImage = filter.outputImage else { return nil }
+
+ // Scale up for better quality on watch display
+ let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
+
+ guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
+
+ // Convert to PNG data for syncing
+ let uiImage = UIImage(cgImage: cgImage)
+ return uiImage.pngData()
+ }
+}
+
+// MARK: - WCSessionDelegate
+
+extension WatchConnectivityService: WCSessionDelegate {
+ nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
+ Task { @MainActor in
+ if let error = error {
+ Design.debugLog("WatchConnectivity: Activation failed: \(error)")
+ } else {
+ Design.debugLog("WatchConnectivity: Activated with state: \(activationState.rawValue)")
+ WatchConnectivityService.shared.handleActivation()
+ }
+ }
+ }
+
+ nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
+ Task { @MainActor in
+ Design.debugLog("WatchConnectivity: Session became inactive")
+ }
+ }
+
+ nonisolated func sessionDidDeactivate(_ session: WCSession) {
+ Task { @MainActor in
+ Design.debugLog("WatchConnectivity: Session deactivated, reactivating...")
+ WCSession.default.activate()
+ }
+ }
+}
+
+/// A simplified card structure that can be shared between iOS and watchOS
+struct SyncableCard: Codable, Identifiable {
+ let id: UUID
+ var displayName: String
+ var role: String
+ var company: String
+ var email: String
+ var phone: String
+ var website: String
+ var location: String
+ var isDefault: Bool
+ var pronouns: String
+ var bio: String
+ var linkedIn: String
+ var twitter: String
+ var instagram: String
+ /// Pre-generated QR code PNG data (CoreImage not available on watchOS)
+ var qrCodeImageData: Data?
+}
diff --git a/BusinessCard/Services/WatchSyncService.swift b/BusinessCard/Services/WatchSyncService.swift
deleted file mode 100644
index 90b8c85..0000000
--- a/BusinessCard/Services/WatchSyncService.swift
+++ /dev/null
@@ -1,201 +0,0 @@
-import Foundation
-import CoreImage
-import CoreImage.CIFilterBuiltins
-import UIKit
-
-/// Syncs card data to watchOS via shared App Group UserDefaults
-struct WatchSyncService {
- private static let appGroupID = "group.com.mbrucedogs.BusinessCard"
- private static let cardsKey = "SyncedCards"
-
- private static var sharedDefaults: UserDefaults? {
- UserDefaults(suiteName: appGroupID)
- }
-
- /// Syncs the given cards to the shared App Group for watchOS to read
- @MainActor
- static func syncCards(_ cards: [BusinessCard]) {
- guard let defaults = sharedDefaults else { return }
-
- let syncableCards = cards.map { card in
- // Get first email, phone, website, location from contact fields
- let email = card.firstContactField(ofType: "email")?.value ?? ""
- let phone = card.firstContactField(ofType: "phone")?.value ?? ""
- let website = card.firstContactField(ofType: "website")?.value ?? ""
- let linkedIn = card.firstContactField(ofType: "linkedIn")?.value ?? ""
- let twitter = card.firstContactField(ofType: "twitter")?.value ?? ""
- let instagram = card.firstContactField(ofType: "instagram")?.value ?? ""
-
- // Format address for display (decode from stored JSON/legacy format)
- let addressValue = card.firstContactField(ofType: "address")?.value ?? ""
- let location = PostalAddress.decode(from: addressValue)?.singleLineString ?? addressValue
-
- // Build vCard payload for QR code generation
- let vCardPayload = buildVCardPayload(
- displayName: card.displayName,
- company: card.company,
- role: card.role,
- phone: phone,
- email: email,
- website: website,
- location: location,
- bio: card.bio,
- linkedIn: linkedIn,
- twitter: twitter,
- instagram: instagram
- )
-
- // Generate QR code image data on iOS (CoreImage not available on watchOS)
- let qrImageData = generateQRCodePNGData(from: vCardPayload)
-
- return SyncableCard(
- id: card.id,
- displayName: card.displayName,
- role: card.role,
- company: card.company,
- email: email,
- phone: phone,
- website: website,
- location: location,
- isDefault: card.isDefault,
- pronouns: card.pronouns,
- bio: card.bio,
- linkedIn: linkedIn,
- twitter: twitter,
- instagram: instagram,
- qrCodeImageData: qrImageData
- )
- }
-
- if let encoded = try? JSONEncoder().encode(syncableCards) {
- defaults.set(encoded, forKey: cardsKey)
- }
- }
-
- private static func buildVCardPayload(
- displayName: String,
- company: String,
- role: String,
- phone: String,
- email: String,
- website: String,
- location: String,
- bio: String,
- linkedIn: String,
- twitter: String,
- instagram: String
- ) -> String {
- var lines = [
- "BEGIN:VCARD",
- "VERSION:3.0",
- "FN:\(displayName)",
- "ORG:\(company)",
- "TITLE:\(role)"
- ]
-
- if !phone.isEmpty {
- lines.append("TEL;TYPE=work:\(phone)")
- }
- if !email.isEmpty {
- lines.append("EMAIL;TYPE=work:\(email)")
- }
- if !website.isEmpty {
- lines.append("URL:\(website)")
- }
- if !location.isEmpty {
- lines.append("ADR;TYPE=work:;;\(location)")
- }
- if !bio.isEmpty {
- lines.append("NOTE:\(bio)")
- }
- if !linkedIn.isEmpty {
- lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(linkedIn)")
- }
- if !twitter.isEmpty {
- lines.append("X-SOCIALPROFILE;TYPE=twitter:\(twitter)")
- }
- if !instagram.isEmpty {
- lines.append("X-SOCIALPROFILE;TYPE=instagram:\(instagram)")
- }
-
- lines.append("END:VCARD")
- return lines.joined(separator: "\n")
- }
-
- private static func generateQRCodePNGData(from payload: String) -> Data? {
- let context = CIContext()
- let data = Data(payload.utf8)
- let filter = CIFilter.qrCodeGenerator()
- filter.setValue(data, forKey: "inputMessage")
- filter.correctionLevel = "M"
-
- guard let outputImage = filter.outputImage else { return nil }
-
- // Scale up for better quality on watch display
- let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
-
- guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
-
- // Convert to PNG data for syncing
- let uiImage = UIImage(cgImage: cgImage)
- return uiImage.pngData()
- }
-}
-
-/// A simplified card structure that can be shared between iOS and watchOS
-struct SyncableCard: Codable, Identifiable {
- let id: UUID
- var displayName: String
- var role: String
- var company: String
- var email: String
- var phone: String
- var website: String
- var location: String
- var isDefault: Bool
- var pronouns: String
- var bio: String
- var linkedIn: String
- var twitter: String
- var instagram: String
- /// Pre-generated QR code PNG data (CoreImage not available on watchOS)
- var qrCodeImageData: Data?
-
- var vCardPayload: String {
- var lines = [
- "BEGIN:VCARD",
- "VERSION:3.0",
- "FN:\(displayName)",
- "ORG:\(company)",
- "TITLE:\(role)"
- ]
-
- if !phone.isEmpty {
- lines.append("TEL;TYPE=work:\(phone)")
- }
- if !email.isEmpty {
- lines.append("EMAIL;TYPE=work:\(email)")
- }
- if !website.isEmpty {
- lines.append("URL:\(website)")
- }
- if !location.isEmpty {
- lines.append("ADR;TYPE=work:;;\(location)")
- }
- if !bio.isEmpty {
- lines.append("NOTE:\(bio)")
- }
- if !linkedIn.isEmpty {
- lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(linkedIn)")
- }
- if !twitter.isEmpty {
- lines.append("X-SOCIALPROFILE;TYPE=twitter:\(twitter)")
- }
- if !instagram.isEmpty {
- lines.append("X-SOCIALPROFILE;TYPE=instagram:\(instagram)")
- }
-
- lines.append("END:VCARD")
- return lines.joined(separator: "\n")
- }
-}
diff --git a/BusinessCard/State/CardStore.swift b/BusinessCard/State/CardStore.swift
index d3750d4..c33d0e5 100644
--- a/BusinessCard/State/CardStore.swift
+++ b/BusinessCard/State/CardStore.swift
@@ -1,6 +1,7 @@
import Foundation
import Observation
import SwiftData
+import Bedrock
@Observable
@MainActor
@@ -41,6 +42,7 @@ final class CardStore: BusinessCardProviding {
}
func updateCard(_ card: BusinessCard) {
+ Design.debugLog("CardStore: updateCard called for: \(card.displayName)")
card.updatedAt = .now
saveContext()
fetchCards()
@@ -90,6 +92,7 @@ final class CardStore: BusinessCardProviding {
}
private func syncToWatch() {
- WatchSyncService.syncCards(cards)
+ Design.debugLog("CardStore: syncToWatch called with \(cards.count) cards")
+ WatchConnectivityService.shared.syncCards(cards)
}
}
diff --git a/BusinessCardWatch Watch App/BusinessCardWatch.entitlements b/BusinessCardWatch Watch App/BusinessCardWatch.entitlements
index 901895b..0c67376 100644
--- a/BusinessCardWatch Watch App/BusinessCardWatch.entitlements
+++ b/BusinessCardWatch Watch App/BusinessCardWatch.entitlements
@@ -1,10 +1,5 @@
-
- com.apple.security.application-groups
-
- group.com.mbrucedogs.BusinessCard
-
-
+
diff --git a/BusinessCardWatch Watch App/BusinessCardWatchApp.swift b/BusinessCardWatch Watch App/BusinessCardWatchApp.swift
index 2670551..155c8e4 100644
--- a/BusinessCardWatch Watch App/BusinessCardWatchApp.swift
+++ b/BusinessCardWatch Watch App/BusinessCardWatchApp.swift
@@ -4,6 +4,16 @@ import SwiftUI
struct BusinessCardWatchApp: App {
@State private var cardStore = WatchCardStore()
+ init() {
+ // Wire up WatchConnectivity to receive cards from iPhone
+ let store = cardStore
+ WatchConnectivityService.shared.onCardsReceived = { cards in
+ Task { @MainActor in
+ store.updateCards(cards)
+ }
+ }
+ }
+
var body: some Scene {
WindowGroup {
WatchContentView()
diff --git a/BusinessCardWatch Watch App/Design/WatchDesignConstants.swift b/BusinessCardWatch Watch App/Design/WatchDesignConstants.swift
index c706eed..5be895b 100644
--- a/BusinessCardWatch Watch App/Design/WatchDesignConstants.swift
+++ b/BusinessCardWatch Watch App/Design/WatchDesignConstants.swift
@@ -1,6 +1,13 @@
import SwiftUI
enum WatchDesign {
+ /// Debug logging for watch app - only prints in DEBUG builds
+ static func debugLog(_ message: String) {
+ #if DEBUG
+ print("[WatchApp] \(message)")
+ #endif
+ }
+
enum Spacing {
static let small: CGFloat = 6
static let medium: CGFloat = 10
diff --git a/BusinessCardWatch Watch App/Services/WatchConnectivityService.swift b/BusinessCardWatch Watch App/Services/WatchConnectivityService.swift
new file mode 100644
index 0000000..be6f6f9
--- /dev/null
+++ b/BusinessCardWatch Watch App/Services/WatchConnectivityService.swift
@@ -0,0 +1,118 @@
+import Foundation
+import WatchConnectivity
+
+/// Manages WatchConnectivity session and receives card data from iPhone
+@MainActor
+final class WatchConnectivityService: NSObject {
+ static let shared = WatchConnectivityService()
+
+ /// Callback when cards are received from iPhone
+ var onCardsReceived: (([WatchCard]) -> Void)?
+
+ private var session: WCSession?
+
+ private override init() {
+ super.init()
+ if WCSession.isSupported() {
+ session = WCSession.default
+ session?.delegate = self
+ session?.activate()
+ WatchDesign.debugLog("WatchConnectivity: Session activating...")
+ } else {
+ WatchDesign.debugLog("WatchConnectivity: Not supported")
+ }
+ }
+
+ /// Check for any existing application context on launch
+ func checkForExistingContext() {
+ guard let session = session else { return }
+
+ let context = session.receivedApplicationContext
+ if !context.isEmpty {
+ WatchDesign.debugLog("WatchConnectivity: Found existing context on launch")
+ processReceivedContext(context)
+ } else {
+ WatchDesign.debugLog("WatchConnectivity: No existing context found")
+ }
+ }
+
+ private func processReceivedContext(_ context: [String: Any]) {
+ guard let cardsData = context["cards"] as? Data else {
+ WatchDesign.debugLog("WatchConnectivity: No cards data in context")
+ return
+ }
+
+ WatchDesign.debugLog("WatchConnectivity: Received \(cardsData.count) bytes")
+
+ do {
+ let syncableCards = try JSONDecoder().decode([SyncableCard].self, from: cardsData)
+ let watchCards = syncableCards.map { syncable in
+ WatchCard(
+ id: syncable.id,
+ displayName: syncable.displayName,
+ role: syncable.role,
+ company: syncable.company,
+ email: syncable.email,
+ phone: syncable.phone,
+ website: syncable.website,
+ location: syncable.location,
+ isDefault: syncable.isDefault,
+ qrCodeImageData: syncable.qrCodeImageData
+ )
+ }
+ WatchDesign.debugLog("WatchConnectivity: Decoded \(watchCards.count) cards")
+ onCardsReceived?(watchCards)
+ } catch {
+ WatchDesign.debugLog("WatchConnectivity: ERROR - Failed to decode: \(error)")
+ }
+ }
+}
+
+// MARK: - WCSessionDelegate
+
+extension WatchConnectivityService: WCSessionDelegate {
+ nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
+ Task { @MainActor in
+ if let error = error {
+ WatchDesign.debugLog("WatchConnectivity: Activation failed: \(error)")
+ } else {
+ WatchDesign.debugLog("WatchConnectivity: Activated with state: \(activationState.rawValue)")
+ // Check for existing context after activation
+ checkForExistingContext()
+ }
+ }
+ }
+
+ nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
+ Task { @MainActor in
+ WatchDesign.debugLog("WatchConnectivity: Received application context update")
+ processReceivedContext(applicationContext)
+ }
+ }
+
+ nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
+ Task { @MainActor in
+ WatchDesign.debugLog("WatchConnectivity: Received userInfo transfer")
+ processReceivedContext(userInfo)
+ }
+ }
+}
+
+/// Syncable card structure matching iOS side (for decoding)
+private struct SyncableCard: Codable, Identifiable {
+ let id: UUID
+ var displayName: String
+ var role: String
+ var company: String
+ var email: String
+ var phone: String
+ var website: String
+ var location: String
+ var isDefault: Bool
+ var pronouns: String
+ var bio: String
+ var linkedIn: String
+ var twitter: String
+ var instagram: String
+ var qrCodeImageData: Data?
+}
diff --git a/BusinessCardWatch Watch App/State/WatchCardStore.swift b/BusinessCardWatch Watch App/State/WatchCardStore.swift
index fd98a01..bfb6b1c 100644
--- a/BusinessCardWatch Watch App/State/WatchCardStore.swift
+++ b/BusinessCardWatch Watch App/State/WatchCardStore.swift
@@ -4,8 +4,6 @@ import Observation
@Observable
@MainActor
final class WatchCardStore {
- private static let appGroupID = "group.com.mbrucedogs.BusinessCard"
- private static let cardsKey = "SyncedCards"
private static let defaultCardIDKey = "WatchDefaultCardID"
private(set) var cards: [WatchCard] = []
@@ -15,28 +13,27 @@ final class WatchCardStore {
}
}
- private var sharedDefaults: UserDefaults? {
- UserDefaults(suiteName: Self.appGroupID)
- }
-
init() {
- loadCards()
loadDefaultID()
+ WatchDesign.debugLog("WatchCardStore: Initialized, waiting for cards from iPhone")
}
var defaultCard: WatchCard? {
guard let defaultCardID else { return cards.first(where: { $0.isDefault }) ?? cards.first }
return cards.first(where: { $0.id == defaultCardID }) ?? cards.first(where: { $0.isDefault }) ?? cards.first
}
-
- func loadCards() {
- guard let defaults = sharedDefaults,
- let data = defaults.data(forKey: Self.cardsKey),
- let decoded = try? JSONDecoder().decode([WatchCard].self, from: data) else {
- cards = []
- return
+
+ /// Called by WatchConnectivityService when cards are received from iPhone
+ func updateCards(_ newCards: [WatchCard]) {
+ WatchDesign.debugLog("WatchCardStore: Received \(newCards.count) cards from iPhone")
+ cards = newCards
+
+ // Update default card ID if current selection is no longer valid
+ if let currentDefault = defaultCardID, !cards.contains(where: { $0.id == currentDefault }) {
+ defaultCardID = cards.first(where: { $0.isDefault })?.id ?? cards.first?.id
+ } else if defaultCardID == nil {
+ defaultCardID = cards.first(where: { $0.isDefault })?.id ?? cards.first?.id
}
- cards = decoded
}
func setDefault(_ card: WatchCard) {
@@ -49,10 +46,8 @@ final class WatchCardStore {
private func loadDefaultID() {
let storedValue = UserDefaults.standard.string(forKey: Self.defaultCardIDKey) ?? ""
- if let id = UUID(uuidString: storedValue), cards.contains(where: { $0.id == id }) {
+ if let id = UUID(uuidString: storedValue) {
defaultCardID = id
- } else {
- defaultCardID = cards.first(where: { $0.isDefault })?.id ?? cards.first?.id
}
}
}
diff --git a/BusinessCardWatch Watch App/Views/WatchContentView.swift b/BusinessCardWatch Watch App/Views/WatchContentView.swift
index cd5b782..08dfa12 100644
--- a/BusinessCardWatch Watch App/Views/WatchContentView.swift
+++ b/BusinessCardWatch Watch App/Views/WatchContentView.swift
@@ -19,9 +19,6 @@ struct WatchContentView: View {
.padding(WatchDesign.Spacing.medium)
}
.background(Color.WatchPalette.background)
- .onAppear {
- cardStore.loadCards()
- }
}
}
@@ -74,8 +71,8 @@ private struct WatchQRCodeCardView: View {
.foregroundStyle(Color.WatchPalette.muted)
}
.frame(width: WatchDesign.Size.qrSize, height: WatchDesign.Size.qrSize)
- .background(Color.WatchPalette.card)
- .clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
+ .background(Color.WatchPalette.card)
+ .clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
}
Text(card.displayName)
diff --git a/README.md b/README.md
index ba54fb2..b37cf46 100644
--- a/README.md
+++ b/README.md
@@ -98,7 +98,7 @@ Each field has:
- Shows the default card QR code
- Pick which card is the default on watch
-- **Syncs with iPhone** via App Groups
+- **Syncs with iPhone** via WatchConnectivity
## Data Sync
@@ -108,9 +108,9 @@ Cards and contacts are stored using SwiftData with CloudKit sync enabled. Your d
### iPhone to Watch Sync
-The iPhone app syncs card data to the paired Apple Watch via App Groups. When you create, edit, or delete cards on your iPhone, the changes appear on your watch.
+The iPhone app syncs card data to the paired Apple Watch via WatchConnectivity framework. When you create, edit, or delete cards on your iPhone, the changes are pushed to your watch automatically.
-**Note**: The watch reads data from the iPhone. To update cards on the watch, make changes on the iPhone first.
+**Note**: The watch receives data from the iPhone. To update cards on the watch, make changes on the iPhone first. QR codes are pre-generated on iPhone since CoreImage is not available on watchOS.
## Architecture
@@ -170,13 +170,13 @@ BusinessCardTests/ # Unit tests
**iOS Target:**
- iCloud (CloudKit enabled)
-- App Groups (`group.com.mbrucedogs.BusinessCard`)
+- App Groups (`group.com.mbrucedogs.BusinessCard`) - for future widget access
- Background Modes (Remote notifications)
- Camera (for QR code scanning)
**watchOS Target:**
-- App Groups (`group.com.mbrucedogs.BusinessCard`)
+- WatchConnectivity (for receiving cards from iPhone)
### CloudKit Container