# CODEX Plan - App Clip ## Goal Implement App Clip-based sharing so recipients can scan a QR code, open an App Clip, preview a card (including photo), and add it to Contacts without installing the full app. ## Key Requirement (Photo Preservation) - Use **vCard 3.0** with embedded photo data for contact creation. - Current model already supports this via `BusinessCard.vCardFilePayload`: - Includes `VERSION:3.0`. - Embeds `PHOTO;ENCODING=b;TYPE=JPEG:`. - App Clip should fetch the record, build a contact with **photo data** attached, and then save to Contacts. ## Data Flow (End-to-End) 1. User taps **Share via App Clip** in iOS app. 2. iOS app creates a `SharedCard` CloudKit record: - Stores vCard data (including PHOTO base64) + optional photo asset. - Sets `expiresAt`. 3. iOS app generates an App Clip URL and a QR code from that URL. 4. Recipient scans QR code → App Clip opens with `recordID` in URL. 5. App Clip fetches `SharedCard` record from CloudKit. 6. App Clip builds a contact from the vCard + photo, shows preview. 7. User taps **Add to Contacts** → contact saved with photo. 8. App Clip marks record as consumed or deletes it (optional). ## UX Expectations - App Clip must load quickly and feel instant. - Preview shows name, role, company, and profile photo. - Primary action is always **Add to Contacts**. - Success state shows confirmation and optionally offers “Open full app”. - Errors show a brief, user-friendly message with retry. ## CloudKit Record Design Record type: `SharedCard` - `recordID` (system) - `fullName` (String) - `role` (String) - `company` (String) - `vCardData` (String) // vCard 3.0 with PHOTO base64 - `photoData` (CKAsset, optional) // optional raw photo file for preview - `createdAt` (Date) - `expiresAt` (Date) - `consumedAt` (Date, optional) // set after App Clip saves contact **Notes:** - Keep TTL short (e.g., 7 days) for privacy. - Use CKAsset only for preview, vCard remains primary source of truth. ## URL Format - `https://yourapp.com/appclip?card=` - Record ID must be safe for URL; use CKRecord.ID recordName. - QR code payload should be only the URL (no vCard data). ## Security / Privacy - Short-lived records; delete expired on app launch and optionally after consumption. - Keep payload minimal: avoid storing unnecessary data. - Consider hashing record IDs in public URLs if desired (optional). - No analytics or tracking unless explicitly approved. ## Constraints - iOS 26+, Swift 6.2, SwiftUI + `@Observable`. - Protocol-first, Clean Architecture, one public type per file. - String Catalogs only; no inline literals in views; use Design constants. - No UIKit unless required (App Clip will need `CNContactStore`). - WatchConnectivity remains unchanged. ## Milestone 1: CloudKit Schema + Core Services (Main App) - Add `SharedCard` CloudKit record design: - Fields: `fullName`, `role`, `company`, `vCardData`, `photoData`, `expiresAt`, `createdAt`. - Store `photoData` as `CKAsset` for size efficiency. - Create protocols: - `SharedCardUploading` (upload + return URL/record ID). - `SharedCardFetching` (fetch by record ID). - `SharedCardCleaning` (delete expired records). - Implement a CloudKit service: - `SharedCardCloudKitService` conforming to the protocols. - Use async/await; no force-try. - Add state integration: - `ShareCardState` (or extend existing share state) to call upload and expose App Clip URL. - Persist a short-lived “last shared” URL for display. - Add cleanup hook on app launch: - Trigger `cleanupExpiredSharedCards()` in `AppState` init or a dedicated startup service. - Cleanup should be safe when offline (no crash, no force-try). ### CloudKit Behavior Details - Use the private database (not public) unless sharing requires public discovery. - Upload is write-once for a given share, no edits required. - `expiresAt` should be server-trusted if possible; otherwise use client time. - Cleanup query: `expiresAt < now` and optionally `consumedAt != nil`. - Prefer batching deletes to avoid rate limits. ### Code Skeletons (Milestone 1) ```swift // BusinessCard/Protocols/SharedCardUploading.swift protocol SharedCardUploading { func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult } ``` ```swift // BusinessCard/Protocols/SharedCardFetching.swift protocol SharedCardFetching { func fetchSharedCard(recordID: String) async throws -> SharedCardPayload } ``` ```swift // BusinessCard/Protocols/SharedCardCleaning.swift protocol SharedCardCleaning { func cleanupExpiredSharedCards() async throws } ``` ```swift // BusinessCard/Models/SharedCardPayload.swift struct SharedCardPayload: Sendable { let recordID: String let vCardData: String let photoData: Data? let expiresAt: Date } ``` ```swift // BusinessCard/Models/SharedCardUploadResult.swift struct SharedCardUploadResult: Sendable { let recordID: String let appClipURL: URL let expiresAt: Date } ``` ```swift // BusinessCard/Services/SharedCardCloudKitService.swift struct SharedCardCloudKitService: SharedCardUploading, SharedCardFetching, SharedCardCleaning { func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult { // Build vCard data using card.vCardFilePayload (includes photo base64). // Upload record + asset, return App Clip URL. } func fetchSharedCard(recordID: String) async throws -> SharedCardPayload { // Fetch vCard string + photo asset for App Clip. } func cleanupExpiredSharedCards() async throws { // Query by expiresAt < now, delete records. } } ``` ## App Clip Entitlements and Capabilities - App Clip target must include: - CloudKit capability - Contacts access usage description - Associated Domains ## Milestone 2: Main App UI Integration - Add “Share via App Clip” action in `ShareCardView`. - Show a new QR code using App Clip URL (not the vCard URL). - Add status UI for upload progress and errors. - Ensure new strings are in `.xcstrings`. - Add user-facing helper copy: - “This QR code opens an App Clip with your full card and photo.” - “Recipient can save the full contact without installing the app.” ## Milestone 2.1: App Clip QR UI Details - Use a dedicated QR view when App Clip URL is available. - Provide a fallback state if upload fails (hide QR + show error). - Show a lightweight loading state while upload is running. ### Code Skeletons (Milestone 2) ```swift // BusinessCard/State/ShareCardState.swift @MainActor @Observable final class ShareCardState { private let uploader: SharedCardUploading var isUploading = false var appClipURL: URL? var lastUploadError: String? init(uploader: SharedCardUploading) { self.uploader = uploader } func shareViaAppClip(card: BusinessCard) async { // Call uploader.uploadSharedCard(card) and set appClipURL. } } ``` ### Suggested ShareCardState Flow - Guard that a card exists before upload. - Set `isUploading = true` before calling CloudKit. - On success: set `appClipURL`, clear `lastUploadError`. - On failure: set `lastUploadError`, keep `appClipURL` nil. - Always reset `isUploading` in `defer`. ## Milestone 3: App Clip Target - Create App Clip target (<15 MB). - Add minimal model type: - `SharedCardSnapshot` (name, role, company, photo, vCardData). - Add state: - `AppClipCardStore` (`@Observable`, `@MainActor`). - Add services: - `AppClipCloudKitService` (fetch by record ID). - Build UI: - `AppClipRootView` with card preview. - “Add to Contacts” primary action. - Implement `CNContactStore` save flow: - Map to `CNMutableContact`. - Respect photo data and vCard fields. ### Code Skeletons (Milestone 3) ```swift // BusinessCardAppClip/Models/SharedCardSnapshot.swift struct SharedCardSnapshot: Sendable { let vCardData: String let photoData: Data? } ``` ```swift // BusinessCardAppClip/Services/AppClipCloudKitService.swift struct AppClipCloudKitService { func fetchSharedCard(recordID: String) async throws -> SharedCardSnapshot { // Fetch vCard + photo asset. } } ``` ```swift // BusinessCardAppClip/State/AppClipCardStore.swift @MainActor @Observable final class AppClipCardStore { private let service: AppClipCloudKitService var snapshot: SharedCardSnapshot? var isLoading = false var errorMessage: String? init(service: AppClipCloudKitService) { self.service = service } func load(recordID: String) async { // Fetch snapshot. } func saveToContacts() async throws { // Parse vCard data + photo and write with CNContactStore. } } ``` ```swift // BusinessCardAppClip/Views/AppClipRootView.swift struct AppClipRootView: View { @Bindable var store: AppClipCardStore var body: some View { // Minimal preview + "Add to Contacts" button. } } ``` ### Contact Save Implementation Sketch ```swift // BusinessCardAppClip/Services/ContactSaveService.swift struct ContactSaveService { func save(vCardData: String, photoData: Data?) async throws { // 1. Convert vCard string to Data. // 2. Parse using CNContactVCardSerialization. // 3. Create CNMutableContact, attach imageData if needed. // 4. Save via CNContactStore. } } ``` ## App Clip Contact Save Details - Parse vCard data into `CNContact` using `CNContactVCardSerialization`. - If vCard photo is missing or invalid, use `photoData` asset as fallback. - Do not create duplicates if a similar contact already exists (optional, future). - Provide success confirmation and a short success message. ## App Clip Permissions - Request Contacts access on first save attempt. - If denied, show an explanatory message with Settings guidance. ## Error States - CloudKit fetch fails → show retry + short description. - Record expired → show “This card has expired” and provide fallback. - Contact save denied → show “Contacts access required” with instructions. - Malformed vCard → show “This card data is invalid” (log details). ## Milestone 4: App Clip Invocation + App Store Connect - Configure Associated Domains: `appclips:yourapp.com`. - Generate App Clip URL format: `https://yourapp.com/appclip?card=`. - Configure App Clip Experience in App Store Connect. - Validate invocation with QR code scans. ## App Clip Size and Performance Notes - Keep App Clip under 15 MB. - Avoid loading unused assets. - Use lightweight preview UI; no heavy animations. ## Implementation Notes - Use `String(localized:)` for non-`Text` strings in App Clip. - Keep all user-facing text in the App Clip string catalog. - Use Bedrock `Design` constants for spacing, corner radius, opacity, and animation. - Avoid `GeometryReader`; use `containerRelativeFrame()` when possible. - Use `.scrollIndicators(.hidden)` if any ScrollView is used. - Ensure all interactive elements have accessibility labels/hints. ## vCard Handling Notes - vCard payload should remain ASCII-safe; base64 PHOTO may be long. - vCard should be stored exactly as generated by `vCardFilePayload`. - If App Clip uses `CNContactVCardSerialization`, preserve any existing fields. - The App Clip should not attempt to parse fields manually unless needed for preview. ## CloudKit Limits and Resilience - CKRecord size limits apply; keep vCard payload small where possible. - Photo asset should be a compressed JPEG to reduce size. - If upload fails, allow retry and show a clear error state. - If fetch fails due to network, show offline messaging and retry. ## Testing Strategy - Unit tests for CloudKit upload URL creation and expiration logic. - Unit test for App Clip contact mapping (vCard + photo). - Manual tests on device for App Clip invocation and contact save. ## Open Questions (Resolve Before Build) - Final App Clip domain and URL path? - TTL for shared cards (3 days vs 7 days)? - Should consumed records be deleted immediately or retained until expiry? - Should App Clip show a minimal card preview or a full card layout? - Do we want a “Copy contact info” fallback if Contacts permission is denied? ## App Clip UI Flow (Screen-by-Screen) 1. **Loading** - Title: “Loading card…” - Show spinner and a short message. - Retry button only if fetch fails. 2. **Card Preview** - Photo (if available), name, role, company. - Primary button: “Add to Contacts”. - Secondary action (optional): “Open Full App”. 3. **Contacts Permission** - If permission not granted, show prompt messaging. - Provide “Continue” → triggers permission request. 4. **Saving** - Progress indicator with short status text. 5. **Success** - Confirmation text: “Saved to Contacts”. - Optional button: “Open Full App”. 6. **Failure** - Short error summary + “Try Again”. - If expired, show “This card has expired”. ## App Store Connect Checklist (App Clip) - Register App Clip in App Store Connect. - Add App Clip Experience: - Title, subtitle, and invocation URL. - Provide header image and action button text. - Configure Advanced Experiences (optional). - Associate App Clip to the main app version. - Verify Associated Domains in the app target. - Upload build with App Clip and validate with Apple. - Test invocation on-device with QR and direct URL. ## Edge Cases - No photo data → vCard should still import. - Large photo → ensure CloudKit asset and vCard remain within limits. - Multiple scans of same QR → record should still fetch until expired. - Offline scanning → show “Requires internet to load” message. ## Files To Add (Expected) - `BusinessCard/Protocols/SharedCardUploading.swift` - `BusinessCard/Protocols/SharedCardFetching.swift` - `BusinessCard/Protocols/SharedCardCleaning.swift` - `BusinessCard/Services/SharedCardCloudKitService.swift` - `BusinessCard/State/ShareCardState.swift` (or extend existing state file) - `BusinessCard/Views/ShareCardView.swift` (update only) - `BusinessCardAppClip/Models/SharedCardSnapshot.swift` - `BusinessCardAppClip/State/AppClipCardStore.swift` - `BusinessCardAppClip/Services/AppClipCloudKitService.swift` - `BusinessCardAppClip/Views/AppClipRootView.swift` ## Documentation Updates (When Implementation Starts) - Update `README.md`, `ai_implementation.md`, `Agents.md` together. - Update `ROADMAP.md` for App Clip progress.