# 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*
