BusinessCard/CODEX-Plan-AppClip.md

395 lines
14 KiB
Markdown

# 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)
```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=<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.