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

This commit is contained in:
Matt Bruce 2026-01-09 16:09:01 -06:00
parent 8596a699c1
commit 3007805011
14 changed files with 300 additions and 66 deletions

View File

@ -32,6 +32,7 @@
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; };
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BusinessCardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -45,11 +46,6 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
05CFDAD65474442D8E3E309E /* BusinessCardWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = BusinessCardWatch;
sourceTree = "<group>";
};
EA8379252F105F2600077F87 /* BusinessCard */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@ -68,6 +64,11 @@
path = BusinessCardUITests;
sourceTree = "<group>";
};
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "BusinessCardWatch Watch App";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@ -100,6 +101,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EA837F952F11B16400077F87 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@ -107,9 +115,9 @@
isa = PBXGroup;
children = (
EA8379252F105F2600077F87 /* BusinessCard */,
05CFDAD65474442D8E3E309E /* BusinessCardWatch */,
EA8379332F105F2800077F87 /* BusinessCardTests */,
EA83793D2F105F2800077F87 /* BusinessCardUITests */,
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */,
EA8379242F105F2600077F87 /* Products */,
);
sourceTree = "<group>";
@ -121,6 +129,7 @@
60186E73BC8040538616865B /* BusinessCardWatch.app */,
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
);
name = Products;
sourceTree = "<group>";
@ -140,9 +149,6 @@
);
dependencies = (
);
fileSystemSynchronizedGroups = (
05CFDAD65474442D8E3E309E /* BusinessCardWatch */,
);
name = BusinessCardWatch;
packageProductDependencies = (
);
@ -219,6 +225,28 @@
productReference = EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA837FA02F11B16400077F87 /* Build configuration list for PBXNativeTarget "BusinessCardWatch Watch App" */;
buildPhases = (
EA837F942F11B16400077F87 /* Sources */,
EA837F952F11B16400077F87 /* Frameworks */,
EA837F962F11B16400077F87 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */,
);
name = "BusinessCardWatch Watch App";
packageProductDependencies = (
);
productName = "BusinessCardWatch Watch App";
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@ -243,6 +271,9 @@
CreatedOnToolsVersion = 26.0;
TestTargetID = EA8379222F105F2600077F87;
};
EA837F972F11B16400077F87 = {
CreatedOnToolsVersion = 26.0;
};
};
};
buildConfigurationList = EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */;
@ -268,6 +299,7 @@
D007169724A44109B518B9E6 /* BusinessCardWatch */,
EA83792F2F105F2800077F87 /* BusinessCardTests */,
EA8379392F105F2800077F87 /* BusinessCardUITests */,
EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */,
);
};
/* End PBXProject section */
@ -301,6 +333,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EA837F962F11B16400077F87 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -332,6 +371,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EA837F942F11B16400077F87 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -675,6 +721,72 @@
};
name = Release;
};
EA837FA12F11B16400077F87 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = .watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
EA837FA22F11B16400077F87 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = .watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -723,6 +835,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA837FA02F11B16400077F87 /* Build configuration list for PBXNativeTarget "BusinessCardWatch Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA837FA12F11B16400077F87 /* Debug */,
EA837FA22F11B16400077F87 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */

View File

@ -7,12 +7,17 @@
<key>BusinessCard.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</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>1</integer>
<integer>2</integer>
</dict>
</dict>
</dict>

View File

@ -1,4 +1,7 @@
import Foundation
import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit
/// Syncs card data to watchOS via shared App Group UserDefaults
struct WatchSyncService {
@ -27,6 +30,24 @@ struct WatchSyncService {
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,
@ -41,7 +62,8 @@ struct WatchSyncService {
bio: card.bio,
linkedIn: linkedIn,
twitter: twitter,
instagram: instagram
instagram: instagram,
qrCodeImageData: qrImageData
)
}
@ -49,6 +71,75 @@ struct WatchSyncService {
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
@ -67,6 +158,8 @@ struct SyncableCard: Codable, Identifiable {
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 = [

View File

@ -1,4 +1,5 @@
import Foundation
import SwiftUI
/// A simplified card structure synced from the iOS app via App Group UserDefaults
struct WatchCard: Codable, Identifiable, Hashable {
@ -11,21 +12,14 @@ struct WatchCard: Codable, Identifiable, Hashable {
var website: String
var location: String
var isDefault: Bool
/// Pre-generated QR code PNG data from iOS (CoreImage not available on watchOS)
var qrCodeImageData: Data?
var vCardPayload: String {
let lines = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:\(displayName)",
"ORG:\(company)",
"TITLE:\(role)",
"TEL;TYPE=work:\(phone)",
"EMAIL;TYPE=work:\(email)",
"URL:\(website)",
"ADR;TYPE=work:;;\(location)",
"END:VCARD"
]
return lines.joined(separator: "\n")
/// Returns a SwiftUI Image from the synced QR code data
var qrCodeImage: Image? {
guard let data = qrCodeImageData,
let uiImage = UIImage(data: data) else { return nil }
return Image(uiImage: uiImage)
}
}
@ -40,7 +34,8 @@ extension WatchCard {
phone: "+1 (214) 987-7810",
website: "wrconstruction.co",
location: "Dallas, TX",
isDefault: true
isDefault: true,
qrCodeImageData: nil
),
WatchCard(
id: UUID(),
@ -51,7 +46,8 @@ extension WatchCard {
phone: "+1 (312) 404-2211",
website: "signal.studio",
location: "Chicago, IL",
isDefault: false
isDefault: false,
qrCodeImageData: nil
),
WatchCard(
id: UUID(),
@ -62,7 +58,8 @@ extension WatchCard {
phone: "+1 (646) 222-3300",
website: "livesessions.fm",
location: "New York, NY",
isDefault: false
isDefault: false,
qrCodeImageData: nil
)
]
}

View File

@ -29,5 +29,6 @@
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Sélectionnée" } }
}
}
}
},
"version" : "1.1"
}

View File

@ -2,13 +2,12 @@ import SwiftUI
struct WatchContentView: View {
@Environment(WatchCardStore.self) private var cardStore
private let qrService = WatchQRCodeService()
var body: some View {
ScrollView {
VStack(spacing: WatchDesign.Spacing.large) {
if let card = cardStore.defaultCard {
WatchQRCodeCardView(card: card, qrService: qrService)
WatchQRCodeCardView(card: card)
} else if cardStore.cards.isEmpty {
WatchEmptyStateView()
}
@ -48,7 +47,6 @@ private struct WatchEmptyStateView: View {
private struct WatchQRCodeCardView: View {
let card: WatchCard
let qrService: WatchQRCodeService
var body: some View {
VStack(spacing: WatchDesign.Spacing.small) {
@ -56,8 +54,8 @@ private struct WatchQRCodeCardView: View {
.font(.headline)
.foregroundStyle(Color.WatchPalette.text)
if let image = qrService.qrCode(from: card.vCardPayload) {
Image(decorative: image, scale: 1)
if let image = card.qrCodeImage {
image
.resizable()
.interpolation(.none)
.scaledToFit()
@ -65,6 +63,19 @@ private struct WatchQRCodeCardView: View {
.padding(WatchDesign.Spacing.small)
.background(Color.WatchPalette.card)
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
} else {
// Fallback when no QR code synced yet
VStack(spacing: WatchDesign.Spacing.small) {
Image(systemName: "qrcode")
.font(.largeTitle)
.foregroundStyle(Color.WatchPalette.muted)
Text("Sync from iPhone")
.font(.caption2)
.foregroundStyle(Color.WatchPalette.muted)
}
.frame(width: WatchDesign.Size.qrSize, height: WatchDesign.Size.qrSize)
.background(Color.WatchPalette.card)
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
}
Text(card.displayName)

View File

@ -1,17 +0,0 @@
import CoreImage
import CoreImage.CIFilterBuiltins
import CoreGraphics
struct WatchQRCodeService {
private let context = CIContext()
func qrCode(from payload: String) -> CGImage? {
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 }
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
return context.createCGImage(scaledImage, from: scaledImage.extent)
}
}

View File

@ -67,22 +67,40 @@ This document tracks planned features and their implementation status.
## 🔲 Planned Features
### Medium Priority (Differentiation)
### High Priority (Photo Sharing via App Clip)
- [ ] **Email signature export** - Generate HTML signature
- Generate professional HTML email signature from card data
- Copy to clipboard or share
- Multiple signature styles/templates
- [ ] **App Clip for instant card sharing** - Recipients get full card with photo, no app install
- Solves the QR code size limitation for photos
- Recipient scans QR → App Clip loads instantly → "Add to Contacts" with photo
- Uses CloudKit for ephemeral card storage (auto-deletes after save)
- [ ] **Card analytics** - View/scan counts
- Would require backend infrastructure
- Track when cards are viewed/scanned
- Show analytics dashboard
**Implementation phases:**
- [ ] **Virtual meeting background** - Generate image with QR
- Create background image with card info + QR code
- Export for Zoom, Teams, etc.
- Multiple background styles
1. **CloudKit Setup**
- [ ] Enable CloudKit capability
- [ ] Create `SharedCard` record type (name, role, company, vCard data, photo asset)
- [ ] Add `expiresAt` field for auto-cleanup
- [ ] Implement upload function in main app
- [ ] Implement cleanup (delete expired cards on app launch)
2. **App Clip Target**
- [ ] Create App Clip target (<15MB)
- [ ] Configure Associated Domains (`appclips:yourapp.com`)
- [ ] Build minimal UI: card preview + "Add to Contacts" button
- [ ] Fetch card from CloudKit using URL parameter
- [ ] Use `CNContactStore` to save contact with photo
- [ ] Call delete/mark-complete on CloudKit after save
3. **Main App Integration**
- [ ] New "Share via App Clip" option in ShareCardView
- [ ] Upload card to CloudKit on share
- [ ] Generate QR code with App Clip URL
- [ ] Show QR code for scanning
4. **App Store Configuration**
- [ ] Configure App Clip experience in App Store Connect
- [ ] Set up App Clip Code or Smart App Banner (optional)
- [ ] Test invocation URLs
### Lower Priority (Advanced)
@ -100,6 +118,11 @@ This document tracks planned features and their implementation status.
- Requires user accounts
- Requires backend infrastructure
- Team branding, shared templates
- [ ] **Track share recipients** - Record who you shared your card with
- Add contact entry when sharing
- Track share date, method, and recipient info
- View share history per card
---
@ -134,4 +157,4 @@ This document tracks planned features and their implementation status.
---
*Last updated: January 8, 2026*
*Last updated: January 9, 2026*