BusinessCard/CODEX-Plan-AppClip.md

14 KiB

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:<base64>.
  • 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=<recordID>
  • 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)

// BusinessCard/Protocols/SharedCardUploading.swift
protocol SharedCardUploading {
    func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult
}
// BusinessCard/Protocols/SharedCardFetching.swift
protocol SharedCardFetching {
    func fetchSharedCard(recordID: String) async throws -> SharedCardPayload
}
// BusinessCard/Protocols/SharedCardCleaning.swift
protocol SharedCardCleaning {
    func cleanupExpiredSharedCards() async throws
}
// BusinessCard/Models/SharedCardPayload.swift
struct SharedCardPayload: Sendable {
    let recordID: String
    let vCardData: String
    let photoData: Data?
    let expiresAt: Date
}
// BusinessCard/Models/SharedCardUploadResult.swift
struct SharedCardUploadResult: Sendable {
    let recordID: String
    let appClipURL: URL
    let expiresAt: Date
}
// 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)

// 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)

// BusinessCardAppClip/Models/SharedCardSnapshot.swift
struct SharedCardSnapshot: Sendable {
    let vCardData: String
    let photoData: Data?
}
// BusinessCardAppClip/Services/AppClipCloudKitService.swift
struct AppClipCloudKitService {
    func fetchSharedCard(recordID: String) async throws -> SharedCardSnapshot {
        // Fetch vCard + photo asset.
    }
}
// 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.
    }
}
// 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

// 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=<recordID>.
  • 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.