BusinessCard/OPUS-Plan-AppClip.md

714 lines
20 KiB
Markdown

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