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

This commit is contained in:
Matt Bruce 2026-01-10 14:58:06 -06:00
parent 7e508be092
commit 4445b59832
2 changed files with 1072 additions and 0 deletions

359
DevAccount-Migration.md Normal file
View File

@ -0,0 +1,359 @@
# Developer Account Migration Guide
This document provides a comprehensive guide for migrating the BusinessCard app from the personal `mbrucedogs` developer account to a new Apple Developer account.
## Current State Analysis
### Account Information
- **Current Team ID**: `6R7KLBPBLZ`
- **Current Bundle ID Prefix**: `com.mbrucedogs`
### Targets and Bundle Identifiers
| Target | Current Bundle ID |
|--------|------------------|
| iOS App | `com.mbrucedogs.BusinessCard` |
| Watch App | `com.mbrucedogs.BusinessCard.watchkitapp` |
| Tests | `com.mbrucedogs.BusinessCardTests` |
| UI Tests | `com.mbrucedogs.BusinessCardUITests` |
| App Clip (planned) | `com.mbrucedogs.BusinessCard.Clip` |
### Entitlements and Services
| Service | Current Identifier | Migration Impact |
|---------|-------------------|------------------|
| CloudKit Container | `iCloud.com.mbrucedogs.BusinessCard` | **HIGH** - Data loss risk |
| App Group | `group.com.mbrucedogs.BusinessCard` | Medium - Local data only |
| Push Notifications | `aps-environment: development` | Low - Re-register |
| Associated Domains (planned) | `appclips:cards.example.com` | Low - Domain-based |
---
## Migration Complexity Rating: MODERATE-HIGH
The main complexity comes from CloudKit. If users have data stored in CloudKit, changing the container identifier means:
- Users lose access to their synced data
- No automatic migration path exists
- Manual data export/import would be required
---
## Pre-Migration Checklist
### Before You Start
- [ ] Create the new Apple Developer Account
- [ ] Note the new Team ID (found in Membership section of developer portal)
- [ ] Decide on new bundle ID prefix (e.g., `com.newcompany`)
- [ ] Plan for CloudKit data migration (if app is in production)
- [ ] If app is NOT yet released, migration is straightforward
---
## Migration Steps
### Step 1: Update Team ID in Xcode
**Files affected**: `project.pbxproj`
1. Open project in Xcode
2. Select project in navigator
3. For each target, go to **Signing & Capabilities**
4. Change **Team** dropdown to new account
5. Xcode will update `DEVELOPMENT_TEAM` in 9 places
**Or manually replace in** `BusinessCard.xcodeproj/project.pbxproj`:
```
Find: DEVELOPMENT_TEAM = 6R7KLBPBLZ;
Replace: DEVELOPMENT_TEAM = NEW_TEAM_ID;
```
---
### Step 2: Update Bundle Identifiers
**Files affected**: `project.pbxproj`
| Old | New |
|-----|-----|
| `com.mbrucedogs.BusinessCard` | `com.newcompany.BusinessCard` |
| `com.mbrucedogs.BusinessCard.watchkitapp` | `com.newcompany.BusinessCard.watchkitapp` |
| `com.mbrucedogs.BusinessCardTests` | `com.newcompany.BusinessCardTests` |
| `com.mbrucedogs.BusinessCardUITests` | `com.newcompany.BusinessCardUITests` |
1. In Xcode, select each target
2. Update **Bundle Identifier** in General tab
3. Verify watch app bundle ID is prefixed with iOS app bundle ID
---
### Step 3: Update CloudKit Container
**Files affected**: `BusinessCard/BusinessCard.entitlements`
```xml
<!-- Old -->
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.mbrucedogs.BusinessCard</string>
</array>
<!-- New -->
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.newcompany.BusinessCard</string>
</array>
```
**CRITICAL**: This creates a NEW, EMPTY CloudKit container. See Data Migration section below.
---
### Step 4: Update App Group
**Files affected**: `BusinessCard/BusinessCard.entitlements`
```xml
<!-- Old -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.mbrucedogs.BusinessCard</string>
</array>
<!-- New -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.newcompany.BusinessCard</string>
</array>
```
**Impact**: App Group is used for sharing data between the iOS app and extensions (not watch). Local data in the old group will be inaccessible.
---
### Step 5: Register New Identifiers in Developer Portal
In the new Apple Developer account portal:
1. **Identifiers > App IDs**
- Register `com.newcompany.BusinessCard`
- Register `com.newcompany.BusinessCard.watchkitapp`
- Register `com.newcompany.BusinessCard.Clip` (for App Clip)
2. **Identifiers > iCloud Containers**
- Create `iCloud.com.newcompany.BusinessCard`
3. **Identifiers > App Groups**
- Create `group.com.newcompany.BusinessCard`
4. **Certificates, Identifiers & Profiles**
- Create new provisioning profiles for all targets
- Xcode can auto-manage this if "Automatically manage signing" is enabled
---
### Step 6: Update Code References
Search and replace in codebase for hardcoded identifiers.
**Files to check** (based on grep results):
- `BusinessCard/BusinessCardApp.swift`
- `README.md`
- `ai_implementation.md`
- Any service files referencing container IDs
**Search for**:
```
com.mbrucedogs
iCloud.com.mbrucedogs
group.com.mbrucedogs
```
**Example in SharedCardCloudKitService** (from OPUS plan):
```swift
// Update default parameter
init(containerID: String = "iCloud.com.newcompany.BusinessCard", ...)
```
---
### Step 7: Update Associated Domains (If App Clip Implemented)
**Files affected**: Both iOS app and App Clip entitlements
The domain itself (`cards.example.com`) doesn't change, but the server's `apple-app-site-association` file needs the new Team ID:
```json
{
"appclips": {
"apps": ["NEW_TEAM_ID.com.newcompany.BusinessCard.Clip"]
},
"applinks": {
"apps": [],
"details": [{
"appID": "NEW_TEAM_ID.com.newcompany.BusinessCard",
"paths": ["/appclip/*"]
}]
}
}
```
---
## CloudKit Data Migration
### Scenario A: App Not Yet Released
**Effort**: None
If the app hasn't been released to users, there's no data to migrate. Simply use the new container.
### Scenario B: App Released, Minimal Users
**Effort**: Low
1. Communicate to users that data will reset
2. Provide in-app export feature before migration (export to vCard files)
3. After update, users re-import their cards
### Scenario C: App Released, Significant Users
**Effort**: HIGH
CloudKit doesn't support container migration. Options:
#### Option 1: Dual Container (Recommended)
Keep access to both containers temporarily:
```xml
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.mbrucedogs.BusinessCard</string>
<string>iCloud.com.newcompany.BusinessCard</string>
</array>
```
Then implement in-app migration:
1. On launch, check old container for data
2. Copy records to new container
3. Delete from old container
4. Remove old container access in future update
**Complexity**: Requires both accounts to grant container access, or the old account must add the new Team ID to the old container's permissions.
#### Option 2: Backend Migration Service
1. Build a server that can access both containers
2. Migrate data server-side
3. Coordinate with app update
**Complexity**: Requires server infrastructure and CloudKit server-to-server keys.
#### Option 3: User-Initiated Export/Import
1. Add "Export All Cards" feature (before migration)
2. Export to files (vCard, JSON, or iCloud Drive)
3. Release update with new container
4. Add "Import Cards" feature
5. Users manually export/import
**Complexity**: Lowest technical effort, but poor user experience.
---
## SwiftData Considerations
SwiftData with CloudKit uses the CloudKit container for sync. When the container changes:
- **Private database records**: Lost (unless migrated)
- **Local SwiftData store**: Remains on device but stops syncing
- **New installs**: Start fresh with empty database
If using SwiftData's automatic CloudKit sync, consider:
1. Disable CloudKit sync temporarily during migration
2. Keep local data intact
3. Re-enable sync with new container
4. Local data will upload to new container
---
## Watch App Considerations
The watch app uses WatchConnectivity (not App Groups) for data sync, so:
- **Bundle ID change**: Required (must match iOS app prefix)
- **Data sync**: Unaffected (syncs from iOS app)
- **Re-pairing**: May need to reinstall watch app after bundle ID change
---
## App Store Considerations
### Same Account, New Bundle ID
- This is a NEW app listing
- Lose reviews, ratings, download history
- Can reference old app in description
### App Transfer (Same Bundle ID)
- If keeping the same bundle ID but transferring ownership
- Use App Store Connect's "Transfer App" feature
- Preserves reviews, ratings, users
- **Requires**: Both accounts in good standing, no active TestFlight, no pending updates
### Recommendation
If possible, use **App Transfer** to maintain continuity. This requires:
1. Old account initiates transfer
2. New account accepts transfer
3. Bundle ID stays the same
4. Only Team ID changes in project
5. CloudKit container permissions may need updates
---
## Complete File Change Summary
| File | Changes Required |
|------|------------------|
| `project.pbxproj` | Team ID (9 places), Bundle IDs (6 places) |
| `BusinessCard.entitlements` | CloudKit container, App Group |
| `BusinessCardWatch.entitlements` | None (currently empty) |
| `SharedCardCloudKitService.swift` | Container ID parameter |
| `README.md` | Update any bundle ID references |
| `ai_implementation.md` | Update any bundle ID references |
| Server AASA file | Update Team ID |
---
## Testing After Migration
1. **Clean build**: Delete derived data, clean build folder
2. **Fresh install**: Delete app from device, reinstall
3. **CloudKit Dashboard**: Verify new container is receiving data
4. **Watch app**: Verify watch installs and syncs correctly
5. **Push notifications**: Verify registration with new bundle ID
6. **App Clip**: Test invocation URL with new Team ID
---
## Timeline Estimate
| Phase | Duration |
|-------|----------|
| Preparation (new account setup) | 1-2 days |
| Code changes | 1-2 hours |
| Testing | 1-2 days |
| CloudKit data migration (if needed) | 1-2 weeks |
| App Store transition | 1-3 days |
---
## Recommendation
**If the app is not yet released**: Migrate now while it's simple.
**If the app is released**: Consider using App Transfer to keep the same bundle ID and minimize disruption. You'll still need to update the Team ID and potentially CloudKit container access.
---
*Last updated: January 10, 2026*

713
OPUS-Plan-AppClip.md Normal file
View File

@ -0,0 +1,713 @@
# OPUS Plan - App Clip for BusinessCard
## Goal
Enable recipients to scan a QR code, open an App Clip, preview a business card with photo, and save to Contacts without installing the full app.
---
## What OPUS Changes from CODEX
| Area | CODEX Approach | OPUS Improvement |
|------|---------------|------------------|
| **Photo Storage** | Stores `vCardData` + separate `photoData` CKAsset | Store `vCardData` only (photo already embedded as base64) |
| **CloudKit Database** | Suggests private, mentions public | **Public database required** for App Clip access |
| **Open Questions** | Lists 5 unresolved questions | All resolved with sensible defaults |
| **TTL** | "3 days vs 7 days?" | **7 days** (re-scans common, privacy still protected) |
| **Post-Save Behavior** | "Delete immediately or retain?" | **Retain until expiry** (allows multiple recipients) |
| **Preview Style** | "Minimal or full?" | **Minimal** (faster load, cleaner UX) |
| **Record Consumption** | Tracks `consumedAt` | **Remove consumption tracking** (over-engineering) |
| **Target Size** | "<15MB" | **<10MB** for noticeably faster App Clip launch |
| **Protocols** | 3 separate protocols | **2 protocols** (combine upload/cleanup in main app) |
| **Code Skeletons** | Placeholders | **Concrete implementations** using existing `vCardFilePayload` |
---
## Resolved Decisions
- **Domain**: Use placeholder `cards.example.com` (update in entitlements before release)
- **TTL**: 7 days
- **Database**: Public (App Clips cannot access private databases)
- **Photo Storage**: vCard only (existing `vCardFilePayload` embeds photo as base64)
- **Post-Save**: Keep record until expired
- **Preview**: Minimal card (photo, name, role, company)
- **App Clip Size**: Target <10MB
---
## Architecture Overview
```mermaid
flowchart TB
subgraph MainApp [Main App]
ShareCardView --> AppClipShareState
AppClipShareState --> SharedCardCloudKitService
SharedCardCloudKitService --> CloudKitPublic
end
subgraph CloudKitPublic [CloudKit Public Database]
SharedCardRecord[SharedCard Record]
end
subgraph AppClipTarget [App Clip]
AppClipRootView --> AppClipCardStore
AppClipCardStore --> AppClipCloudKitService
AppClipCloudKitService --> CloudKitPublic
AppClipCardStore --> ContactSaveService
ContactSaveService --> Contacts
end
QRCode[QR Code] -->|scan| AppClipRootView
ShareCardView -->|generate| QRCode
```
---
## Phase 1: CloudKit Setup (Main App)
### 1.1 Enable CloudKit Capability
In Xcode, ensure the iOS target has:
- iCloud capability enabled
- CloudKit checked
- Container: `iCloud.com.mbrucedogs.BusinessCard`
### 1.2 Create CloudKit Record Model
File: `BusinessCard/Models/SharedCardRecord.swift`
```swift
import CloudKit
/// Represents a shared card in CloudKit public database.
/// NOT a SwiftData model - uses raw CKRecord for ephemeral sharing.
struct SharedCardRecord: Sendable {
let recordID: CKRecord.ID
let vCardData: String
let expiresAt: Date
let createdAt: Date
static let recordType = "SharedCard"
enum Field: String {
case vCardData
case expiresAt
case createdAt
}
init(recordID: CKRecord.ID, vCardData: String, expiresAt: Date, createdAt: Date = .now) {
self.recordID = recordID
self.vCardData = vCardData
self.expiresAt = expiresAt
self.createdAt = createdAt
}
init?(record: CKRecord) {
guard let vCardData = record[Field.vCardData.rawValue] as? String,
let expiresAt = record[Field.expiresAt.rawValue] as? Date else {
return nil
}
self.recordID = record.recordID
self.vCardData = vCardData
self.expiresAt = expiresAt
self.createdAt = (record[Field.createdAt.rawValue] as? Date) ?? record.creationDate ?? .now
}
func toCKRecord() -> CKRecord {
let record = CKRecord(recordType: Self.recordType, recordID: recordID)
record[Field.vCardData.rawValue] = vCardData
record[Field.expiresAt.rawValue] = expiresAt
record[Field.createdAt.rawValue] = createdAt
return record
}
}
```
### 1.3 Create Upload Result Model
File: `BusinessCard/Models/SharedCardUploadResult.swift`
```swift
import Foundation
struct SharedCardUploadResult: Sendable {
let recordName: String
let appClipURL: URL
let expiresAt: Date
}
```
### 1.4 Create Protocol
File: `BusinessCard/Protocols/SharedCardProviding.swift`
```swift
import Foundation
/// Provides shared card upload and cleanup functionality.
protocol SharedCardProviding: Sendable {
func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult
func cleanupExpiredCards() async
}
```
### 1.5 Create CloudKit Service
File: `BusinessCard/Services/SharedCardCloudKitService.swift`
```swift
import CloudKit
import Foundation
struct SharedCardCloudKitService: SharedCardProviding {
private let container: CKContainer
private let database: CKDatabase
private let ttlDays: Int
private let appClipDomain: String
init(
containerID: String = "iCloud.com.mbrucedogs.BusinessCard",
ttlDays: Int = 7,
appClipDomain: String = "cards.example.com"
) {
self.container = CKContainer(identifier: containerID)
self.database = container.publicCloudDatabase
self.ttlDays = ttlDays
self.appClipDomain = appClipDomain
}
@MainActor
func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult {
let vCardData = card.vCardFilePayload
let expiresAt = Date.now.addingTimeInterval(TimeInterval(ttlDays * 24 * 60 * 60))
let recordID = CKRecord.ID(recordName: UUID().uuidString)
let sharedCard = SharedCardRecord(
recordID: recordID,
vCardData: vCardData,
expiresAt: expiresAt
)
let record = sharedCard.toCKRecord()
_ = try await database.save(record)
guard let appClipURL = URL(string: "https://\(appClipDomain)/appclip?id=\(recordID.recordName)") else {
throw SharedCardError.invalidURL
}
return SharedCardUploadResult(
recordName: recordID.recordName,
appClipURL: appClipURL,
expiresAt: expiresAt
)
}
func cleanupExpiredCards() async {
let predicate = NSPredicate(format: "expiresAt < %@", Date.now as NSDate)
let query = CKQuery(recordType: SharedCardRecord.recordType, predicate: predicate)
do {
let (results, _) = try await database.records(matching: query)
let recordIDs = results.compactMap { result -> CKRecord.ID? in
guard case .success = result.1 else { return nil }
return result.0
}
guard !recordIDs.isEmpty else { return }
let operation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIDs)
operation.qualityOfService = .utility
database.add(operation)
} catch {
// Cleanup is best-effort; log but don't throw
}
}
}
enum SharedCardError: Error, LocalizedError {
case invalidURL
case uploadFailed(Error)
case fetchFailed(Error)
case recordNotFound
case recordExpired
var errorDescription: String? {
switch self {
case .invalidURL:
return String(localized: "Failed to create share URL")
case .uploadFailed(let error):
return String(localized: "Upload failed: \(error.localizedDescription)")
case .fetchFailed(let error):
return String(localized: "Could not load card: \(error.localizedDescription)")
case .recordNotFound:
return String(localized: "Card not found")
case .recordExpired:
return String(localized: "This card has expired")
}
}
}
```
### 1.6 Add Cleanup to AppState
Update `BusinessCard/State/AppState.swift` to trigger cleanup on launch:
```swift
// Add to AppState init or a dedicated startup method
Task {
await SharedCardCloudKitService().cleanupExpiredCards()
}
```
---
## Phase 2: Main App UI Integration
### 2.1 Create Share State
File: `BusinessCard/State/AppClipShareState.swift`
```swift
import Foundation
@MainActor
@Observable
final class AppClipShareState {
private let service: SharedCardProviding
var isUploading = false
var uploadResult: SharedCardUploadResult?
var errorMessage: String?
var hasAppClipURL: Bool { uploadResult != nil }
init(service: SharedCardProviding = SharedCardCloudKitService()) {
self.service = service
}
func shareViaAppClip(card: BusinessCard) async {
isUploading = true
errorMessage = nil
uploadResult = nil
defer { isUploading = false }
do {
uploadResult = try await service.uploadSharedCard(card)
} catch {
errorMessage = error.localizedDescription
}
}
func reset() {
uploadResult = nil
errorMessage = nil
}
}
```
### 2.2 Update ShareCardView
Add to `BusinessCard/Views/ShareCardView.swift`:
- New "Share via App Clip" section
- Upload progress indicator
- QR code display for App Clip URL
- Error state with retry
```swift
// Add state property
@State private var appClipState = AppClipShareState()
// Add UI section
Section {
if appClipState.isUploading {
ProgressView()
.accessibilityLabel(Text("Uploading card"))
} else if let result = appClipState.uploadResult {
// Show QR code with result.appClipURL
QRCodeView(content: result.appClipURL.absoluteString)
Text("Expires in 7 days")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Button("Share via App Clip") {
Task { await appClipState.shareViaAppClip(card: card) }
}
}
if let error = appClipState.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
} header: {
Text("App Clip (includes photo)")
}
```
---
## Phase 3: App Clip Target
### 3.1 Create App Clip Target
1. File > New > Target > App Clip
2. Name: `BusinessCardClip`
3. Bundle ID: `com.mbrucedogs.BusinessCard.Clip`
4. Keep size <10MB (no heavy assets)
### 3.2 App Clip Folder Structure
```
BusinessCardClip/
├── BusinessCardClipApp.swift
├── Design/
│ └── ClipDesignConstants.swift
├── Models/
│ └── SharedCardSnapshot.swift
├── Services/
│ ├── ClipCloudKitService.swift
│ └── ContactSaveService.swift
├── State/
│ └── ClipCardStore.swift
├── Views/
│ ├── ClipRootView.swift
│ └── Components/
│ ├── ClipLoadingView.swift
│ ├── ClipCardPreview.swift
│ ├── ClipSuccessView.swift
│ └── ClipErrorView.swift
└── Resources/
└── Localizable.xcstrings
```
### 3.3 App Clip Models
File: `BusinessCardClip/Models/SharedCardSnapshot.swift`
```swift
import Foundation
struct SharedCardSnapshot: Sendable {
let recordName: String
let vCardData: String
let displayName: String
let role: String
let company: String
let photoData: Data?
init(recordName: String, vCardData: String) {
self.recordName = recordName
self.vCardData = vCardData
// Parse display fields from vCard
let lines = vCardData.components(separatedBy: .newlines)
self.displayName = Self.parseField("FN:", from: lines) ?? "Contact"
self.role = Self.parseField("TITLE:", from: lines) ?? ""
self.company = Self.parseField("ORG:", from: lines)?.components(separatedBy: ";").first ?? ""
self.photoData = Self.parsePhoto(from: lines)
}
private static func parseField(_ prefix: String, from lines: [String]) -> String? {
lines.first { $0.hasPrefix(prefix) }?.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces)
}
private static func parsePhoto(from lines: [String]) -> Data? {
guard let photoLine = lines.first(where: { $0.hasPrefix("PHOTO;") }),
let base64Start = photoLine.range(of: ":")?.upperBound else {
return nil
}
let base64String = String(photoLine[base64Start...])
return Data(base64Encoded: base64String)
}
}
```
### 3.4 App Clip CloudKit Service
File: `BusinessCardClip/Services/ClipCloudKitService.swift`
```swift
import CloudKit
struct ClipCloudKitService: Sendable {
private let database: CKDatabase
init(containerID: String = "iCloud.com.mbrucedogs.BusinessCard") {
self.database = CKContainer(identifier: containerID).publicCloudDatabase
}
func fetchSharedCard(recordName: String) async throws -> SharedCardSnapshot {
let recordID = CKRecord.ID(recordName: recordName)
let record: CKRecord
do {
record = try await database.record(for: recordID)
} catch {
throw ClipError.fetchFailed
}
guard let vCardData = record["vCardData"] as? String else {
throw ClipError.invalidRecord
}
if let expiresAt = record["expiresAt"] as? Date, expiresAt < .now {
throw ClipError.expired
}
return SharedCardSnapshot(recordName: recordName, vCardData: vCardData)
}
}
enum ClipError: Error, LocalizedError {
case fetchFailed
case invalidRecord
case expired
case contactSaveFailed
case contactsAccessDenied
var errorDescription: String? {
switch self {
case .fetchFailed: return String(localized: "Could not load card")
case .invalidRecord: return String(localized: "Invalid card data")
case .expired: return String(localized: "This card has expired")
case .contactSaveFailed: return String(localized: "Failed to save contact")
case .contactsAccessDenied: return String(localized: "Contacts access required")
}
}
}
```
### 3.5 Contact Save Service
File: `BusinessCardClip/Services/ContactSaveService.swift`
```swift
import Contacts
struct ContactSaveService: Sendable {
func saveContact(vCardData: String) async throws {
let store = CNContactStore()
let authorized = try await store.requestAccess(for: .contacts)
guard authorized else {
throw ClipError.contactsAccessDenied
}
guard let data = vCardData.data(using: .utf8),
let contact = try CNContactVCardSerialization.contacts(with: data).first else {
throw ClipError.invalidRecord
}
let mutableContact = contact.mutableCopy() as? CNMutableContact ?? CNMutableContact()
let saveRequest = CNSaveRequest()
saveRequest.add(mutableContact, toContainerWithIdentifier: nil)
do {
try store.execute(saveRequest)
} catch {
throw ClipError.contactSaveFailed
}
}
}
```
### 3.6 App Clip State
File: `BusinessCardClip/State/ClipCardStore.swift`
```swift
import Foundation
@MainActor
@Observable
final class ClipCardStore {
enum State {
case loading
case loaded(SharedCardSnapshot)
case saved
case error(String)
}
private let cloudKit: ClipCloudKitService
private let contactSave: ContactSaveService
var state: State = .loading
var snapshot: SharedCardSnapshot? {
if case .loaded(let snap) = state { return snap }
return nil
}
init(cloudKit: ClipCloudKitService = ClipCloudKitService(),
contactSave: ContactSaveService = ContactSaveService()) {
self.cloudKit = cloudKit
self.contactSave = contactSave
}
func load(recordName: String) async {
state = .loading
do {
let snapshot = try await cloudKit.fetchSharedCard(recordName: recordName)
state = .loaded(snapshot)
} catch {
state = .error(error.localizedDescription)
}
}
func saveToContacts() async {
guard let snapshot else { return }
do {
try await contactSave.saveContact(vCardData: snapshot.vCardData)
state = .saved
} catch {
state = .error(error.localizedDescription)
}
}
}
```
### 3.7 App Clip Root View
File: `BusinessCardClip/Views/ClipRootView.swift`
```swift
import SwiftUI
struct ClipRootView: View {
@State private var store = ClipCardStore()
let recordName: String
var body: some View {
Group {
switch store.state {
case .loading:
ClipLoadingView()
case .loaded(let snapshot):
ClipCardPreview(snapshot: snapshot) {
Task { await store.saveToContacts() }
}
case .saved:
ClipSuccessView()
case .error(let message):
ClipErrorView(message: message) {
Task { await store.load(recordName: recordName) }
}
}
}
.task {
await store.load(recordName: recordName)
}
}
}
```
### 3.8 App Clip Entry Point
File: `BusinessCardClip/BusinessCardClipApp.swift`
```swift
import SwiftUI
@main
struct BusinessCardClipApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
handleUserActivity(activity)
}
}
}
private func handleUserActivity(_ activity: NSUserActivity) {
guard let url = activity.webpageURL,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let recordName = components.queryItems?.first(where: { $0.name == "id" })?.value else {
return
}
// Pass recordName to root view via environment or state
}
}
```
---
## Phase 4: Configuration
### 4.1 Associated Domains
Add to both iOS app and App Clip entitlements:
```
appclips:cards.example.com
```
### 4.2 Server Configuration
Create `.well-known/apple-app-site-association` on `cards.example.com`:
```json
{
"appclips": {
"apps": ["TEAMID.com.mbrucedogs.BusinessCard.Clip"]
}
}
```
### 4.3 App Store Connect
1. Register App Clip in App Store Connect
2. Configure App Clip Experience:
- Title: "BusinessCard"
- Subtitle: "View and save contact"
- Action: "Open"
- Invocation URL: `https://cards.example.com/appclip`
---
## Files to Create
| File | Location |
|------|----------|
| `SharedCardRecord.swift` | `BusinessCard/Models/` |
| `SharedCardUploadResult.swift` | `BusinessCard/Models/` |
| `SharedCardProviding.swift` | `BusinessCard/Protocols/` |
| `SharedCardCloudKitService.swift` | `BusinessCard/Services/` |
| `AppClipShareState.swift` | `BusinessCard/State/` |
| `SharedCardSnapshot.swift` | `BusinessCardClip/Models/` |
| `ClipCloudKitService.swift` | `BusinessCardClip/Services/` |
| `ContactSaveService.swift` | `BusinessCardClip/Services/` |
| `ClipCardStore.swift` | `BusinessCardClip/State/` |
| `ClipRootView.swift` | `BusinessCardClip/Views/` |
| Component views | `BusinessCardClip/Views/Components/` |
## Files to Update
| File | Changes |
|------|---------|
| `ShareCardView.swift` | Add App Clip share section |
| `AppState.swift` | Add cleanup call on init |
| `BusinessCard.entitlements` | Add Associated Domains |
---
## Testing Strategy
1. **Unit Tests**: CloudKit upload/fetch logic, vCard parsing, error handling
2. **Integration Tests**: Full upload/fetch cycle (requires CloudKit)
3. **Device Tests**: App Clip invocation via QR scan, contact save
---
## Documentation Updates
When implementation begins, update:
- `README.md` - Add App Clip feature description
- `ai_implementation.md` - Add CloudKit and App Clip architecture
- `ROADMAP.md` - Mark App Clip phases as completed
---
*Last updated: January 10, 2026*