diff --git a/DevAccount-Migration.md b/DevAccount-Migration.md new file mode 100644 index 0000000..871d724 --- /dev/null +++ b/DevAccount-Migration.md @@ -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 + +com.apple.developer.icloud-container-identifiers + + iCloud.com.mbrucedogs.BusinessCard + + + +com.apple.developer.icloud-container-identifiers + + iCloud.com.newcompany.BusinessCard + +``` + +**CRITICAL**: This creates a NEW, EMPTY CloudKit container. See Data Migration section below. + +--- + +### Step 4: Update App Group + +**Files affected**: `BusinessCard/BusinessCard.entitlements` + +```xml + +com.apple.security.application-groups + + group.com.mbrucedogs.BusinessCard + + + +com.apple.security.application-groups + + group.com.newcompany.BusinessCard + +``` + +**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 +com.apple.developer.icloud-container-identifiers + + iCloud.com.mbrucedogs.BusinessCard + iCloud.com.newcompany.BusinessCard + +``` + +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* diff --git a/OPUS-Plan-AppClip.md b/OPUS-Plan-AppClip.md new file mode 100644 index 0000000..09fbac2 --- /dev/null +++ b/OPUS-Plan-AppClip.md @@ -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*