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

This commit is contained in:
Matt Bruce 2026-01-10 12:58:01 -06:00
parent bd6495e1e5
commit 1262377bcb
15 changed files with 440 additions and 352 deletions

View File

@ -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 = (

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array/>
</plist>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "6FB169DC-E619-40A8-968F-910EF3CF4FA4"
type = "1"
version = "2.0">
</Bucket>

View File

@ -7,17 +7,12 @@
<key>BusinessCard.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>0</integer>
</dict>
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>BusinessCardWatch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</integer>
</dict>
</dict>
</dict>

View File

@ -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 {

View File

@ -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?
}

View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -1,10 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.mbrucedogs.BusinessCard</string>
</array>
</dict>
<dict/>
</plist>

View File

@ -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()

View File

@ -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

View File

@ -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?
}

View File

@ -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,13 +13,9 @@ 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? {
@ -29,14 +23,17 @@ final class WatchCardStore {
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
}
}
}

View File

@ -19,9 +19,6 @@ struct WatchContentView: View {
.padding(WatchDesign.Spacing.medium)
}
.background(Color.WatchPalette.background)
.onAppear {
cardStore.loadCards()
}
}
}

View File

@ -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