14 KiB
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>.
- Includes
- App Clip should fetch the record, build a contact with photo data attached, and then save to Contacts.
Data Flow (End-to-End)
- User taps Share via App Clip in iOS app.
- iOS app creates a
SharedCardCloudKit record:- Stores vCard data (including PHOTO base64) + optional photo asset.
- Sets
expiresAt.
- iOS app generates an App Clip URL and a QR code from that URL.
- Recipient scans QR code → App Clip opens with
recordIDin URL. - App Clip fetches
SharedCardrecord from CloudKit. - App Clip builds a contact from the vCard + photo, shows preview.
- User taps Add to Contacts → contact saved with photo.
- 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 base64photoData(CKAsset, optional) // optional raw photo file for previewcreatedAt(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
SharedCardCloudKit record design:- Fields:
fullName,role,company,vCardData,photoData,expiresAt,createdAt. - Store
photoDataasCKAssetfor size efficiency.
- Fields:
- Create protocols:
SharedCardUploading(upload + return URL/record ID).SharedCardFetching(fetch by record ID).SharedCardCleaning(delete expired records).
- Implement a CloudKit service:
SharedCardCloudKitServiceconforming 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()inAppStateinit or a dedicated startup service. - Cleanup should be safe when offline (no crash, no force-try).
- Trigger
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.
expiresAtshould be server-trusted if possible; otherwise use client time.- Cleanup query:
expiresAt < nowand optionallyconsumedAt != 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 = truebefore calling CloudKit. - On success: set
appClipURL, clearlastUploadError. - On failure: set
lastUploadError, keepappClipURLnil. - Always reset
isUploadingindefer.
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:
AppClipRootViewwith card preview.- “Add to Contacts” primary action.
- Implement
CNContactStoresave flow:- Map to
CNMutableContact. - Respect photo data and vCard fields.
- Map to
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
CNContactusingCNContactVCardSerialization. - If vCard photo is missing or invalid, use
photoDataasset 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-Textstrings in App Clip. - Keep all user-facing text in the App Clip string catalog.
- Use Bedrock
Designconstants for spacing, corner radius, opacity, and animation. - Avoid
GeometryReader; usecontainerRelativeFrame()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)
- Loading
- Title: “Loading card…”
- Show spinner and a short message.
- Retry button only if fetch fails.
- Card Preview
- Photo (if available), name, role, company.
- Primary button: “Add to Contacts”.
- Secondary action (optional): “Open Full App”.
- Contacts Permission
- If permission not granted, show prompt messaging.
- Provide “Continue” → triggers permission request.
- Saving
- Progress indicator with short status text.
- Success
- Confirmation text: “Saved to Contacts”.
- Optional button: “Open Full App”.
- 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.swiftBusinessCard/Protocols/SharedCardFetching.swiftBusinessCard/Protocols/SharedCardCleaning.swiftBusinessCard/Services/SharedCardCloudKitService.swiftBusinessCard/State/ShareCardState.swift(or extend existing state file)BusinessCard/Views/ShareCardView.swift(update only)BusinessCardAppClip/Models/SharedCardSnapshot.swiftBusinessCardAppClip/State/AppClipCardStore.swiftBusinessCardAppClip/Services/AppClipCloudKitService.swiftBusinessCardAppClip/Views/AppClipRootView.swift
Documentation Updates (When Implementation Starts)
- Update
README.md,ai_implementation.md,Agents.mdtogether. - Update
ROADMAP.mdfor App Clip progress.