714 lines
20 KiB
Markdown
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*
|