diff --git a/APPCLIP-DEPLOYMENT.md b/APPCLIP-DEPLOYMENT.md
new file mode 100644
index 0000000..c4faf7d
--- /dev/null
+++ b/APPCLIP-DEPLOYMENT.md
@@ -0,0 +1,654 @@
+# App Clip Deployment Guide
+
+This guide walks you through setting up the domain, Cloudflare Worker, and App Store Connect configuration needed for the BusinessCard App Clip to work.
+
+**Total Cost: ~$10-15/year** (domain only - everything else is free)
+**Time: ~1-2 hours**
+
+---
+
+## Overview
+
+```
+User scans QR code
+ ↓
+https://yourdomain.com/card/abc123
+ ↓
+Cloudflare Worker receives request
+ ↓
+┌───────────────────────────────────────┐
+│ Serves AASA file to Apple │
+│ (verifies App Clip ownership) │
+└───────────────────────────────────────┘
+ ↓
+┌───────────┴───────────┐
+↓ ↓
+iOS Android/Browser
+↓ ↓
+App Clip launches Worker fetches vCard
+(native experience) from CloudKit, returns
+ .vcf file download
+```
+
+---
+
+## Phase 1: Register a Domain (~10 minutes)
+
+### Step 1.1: Choose a Domain Name
+
+Pick something short and memorable. Examples:
+- `cards.yourlastname.com` (if you own a personal domain)
+- `bizcards.io`
+- `cardshare.app`
+- `vcards.link`
+
+### Step 1.2: Register the Domain
+
+**Recommended Registrars** (cheapest, no upselling):
+
+| Registrar | .com Price | .io Price | .app Price |
+|-----------|------------|-----------|------------|
+| [Cloudflare Registrar](https://www.cloudflare.com/products/registrar/) | ~$10/yr | ~$35/yr | ~$15/yr |
+| [Porkbun](https://porkbun.com) | ~$10/yr | ~$33/yr | ~$15/yr |
+| [Namecheap](https://namecheap.com) | ~$11/yr | ~$35/yr | ~$16/yr |
+
+**Recommendation**: Register directly with Cloudflare - simplest setup since you'll use Cloudflare Workers anyway.
+
+### Step 1.3: If Not Using Cloudflare Registrar
+
+If you registered elsewhere, add the domain to Cloudflare:
+
+1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com)
+2. Click "Add a Site"
+3. Enter your domain
+4. Select the **Free** plan
+5. Cloudflare will give you 2 nameservers (e.g., `ada.ns.cloudflare.com`)
+6. Go to your registrar and update nameservers to Cloudflare's
+7. Wait for DNS propagation (5 min - 24 hours, usually fast)
+
+---
+
+## Phase 2: Create CloudKit Public Database Schema (~15 minutes)
+
+The App Clip needs to read shared cards from CloudKit's public database.
+
+### Step 2.1: Open CloudKit Console
+
+1. Go to [CloudKit Console](https://icloud.developer.apple.com/dashboard)
+2. Sign in with your Apple Developer account
+3. Select your container: `iCloud.com.mbrucedogs.BusinessCard`
+
+### Step 2.2: Create the Record Type
+
+1. Click **Schema** in the sidebar
+2. Click **Record Types**
+3. Click **+** to create new record type
+4. Name: `SharedCard`
+5. Add these fields:
+
+| Field Name | Type |
+|------------|------|
+| `vCardData` | String |
+| `expiresAt` | Date/Time |
+| `createdAt` | Date/Time |
+
+6. Click **Save**
+
+### Step 2.3: Set Security Roles (IMPORTANT)
+
+1. Click **Security Roles** under your record type
+2. For the **Public** database:
+ - **Authenticated Users**: Read, Write (for uploading from main app)
+ - **World**: Read (for App Clip to fetch without sign-in)
+
+3. Click **Save**
+
+### Step 2.4: Deploy to Production
+
+1. Click **Deploy Schema to Production...**
+2. Confirm the deployment
+
+---
+
+## Phase 3: Set Up Cloudflare Worker (~30 minutes)
+
+### Step 3.1: Create the Worker
+
+1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com)
+2. Click **Workers & Pages** in the sidebar
+3. Click **Create Application**
+4. Click **Create Worker**
+5. Name it: `businesscard-share` (or similar)
+6. Click **Deploy** (creates with placeholder code)
+
+### Step 3.2: Configure Worker Code
+
+1. Click **Edit Code** (or "Quick Edit")
+2. Replace all code with:
+
+```javascript
+// BusinessCard App Clip & vCard Worker
+// Handles iOS App Clip verification and Android vCard downloads
+
+// ============================================================================
+// CONFIGURATION - Update these values
+// ============================================================================
+
+const CONFIG = {
+ // Your Apple Developer Team ID (find in Apple Developer portal)
+ teamId: '6R7KLBPBLZ',
+
+ // Your App Clip bundle identifier
+ appClipBundleId: 'com.mbrucedogs.BusinessCard.Clip',
+
+ // Your CloudKit container identifier
+ cloudKitContainer: 'iCloud.com.mbrucedogs.BusinessCard',
+
+ // CloudKit environment ('development' or 'production')
+ cloudKitEnvironment: 'production',
+};
+
+// ============================================================================
+// MAIN HANDLER
+// ============================================================================
+
+export default {
+ async fetch(request, env, ctx) {
+ const url = new URL(request.url);
+
+ // Handle AASA file for Apple App Clip verification
+ if (url.pathname === '/.well-known/apple-app-site-association') {
+ return handleAASA();
+ }
+
+ // Handle card requests (both App Clip and vCard download)
+ if (url.pathname === '/appclip' || url.pathname.startsWith('/card/')) {
+ return handleCardRequest(request, url);
+ }
+
+ // Handle root - show a simple landing page
+ if (url.pathname === '/') {
+ return handleLandingPage();
+ }
+
+ return new Response('Not Found', { status: 404 });
+ }
+};
+
+// ============================================================================
+// AASA HANDLER - Apple App Clip Verification
+// ============================================================================
+
+function handleAASA() {
+ const aasa = {
+ appclips: {
+ apps: [`${CONFIG.teamId}.${CONFIG.appClipBundleId}`]
+ }
+ };
+
+ return new Response(JSON.stringify(aasa), {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
+ }
+ });
+}
+
+// ============================================================================
+// CARD REQUEST HANDLER
+// ============================================================================
+
+async function handleCardRequest(request, url) {
+ // Extract record ID from URL
+ // Supports: /appclip?id=xxx or /card/xxx
+ let recordId = url.searchParams.get('id');
+ if (!recordId && url.pathname.startsWith('/card/')) {
+ recordId = url.pathname.split('/')[2];
+ }
+
+ if (!recordId) {
+ return new Response('Missing card ID', { status: 400 });
+ }
+
+ // Check if this is an iOS device (App Clip will handle it natively)
+ const userAgent = request.headers.get('User-Agent') || '';
+ const isIOS = /iPhone|iPad|iPod/.test(userAgent);
+
+ // For iOS, we could return a page that triggers App Clip,
+ // but iOS typically intercepts the URL before reaching here.
+ // This fallback is mainly for Android/desktop browsers.
+
+ if (isIOS) {
+ // Return a simple page that might help if App Clip doesn't auto-launch
+ return handleIOSFallback(recordId);
+ }
+
+ // For non-iOS devices, fetch the vCard from CloudKit and serve it
+ return handleVCardDownload(recordId);
+}
+
+// ============================================================================
+// iOS FALLBACK - In case App Clip doesn't auto-launch
+// ============================================================================
+
+function handleIOSFallback(recordId) {
+ const html = `
+
+
+
+
+
+ Opening BusinessCard...
+
+
+
+
+
+
Opening BusinessCard
+
If the app doesn't open automatically, make sure you have iOS 14 or later.
+
+
+`;
+
+ return new Response(html, {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' }
+ });
+}
+
+// ============================================================================
+// VCARD DOWNLOAD - For Android/Desktop browsers
+// ============================================================================
+
+async function handleVCardDownload(recordId) {
+ try {
+ // Fetch the record from CloudKit public database
+ const vCardData = await fetchFromCloudKit(recordId);
+
+ if (!vCardData) {
+ return handleNotFound();
+ }
+
+ // Extract name from vCard for filename
+ const nameMatch = vCardData.match(/FN:(.+)/);
+ const name = nameMatch ? nameMatch[1].trim().replace(/[^a-zA-Z0-9]/g, '_') : 'contact';
+
+ // Return as downloadable vCard file
+ return new Response(vCardData, {
+ headers: {
+ 'Content-Type': 'text/vcard; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${name}.vcf"`,
+ 'Cache-Control': 'private, max-age=300', // Cache for 5 minutes
+ }
+ });
+
+ } catch (error) {
+ console.error('Error fetching vCard:', error);
+ return handleError();
+ }
+}
+
+// ============================================================================
+// CLOUDKIT FETCH
+// ============================================================================
+
+async function fetchFromCloudKit(recordName) {
+ // CloudKit Web Services public database query
+ // Note: For public database reads, no authentication is needed
+
+ const ckURL = `https://api.apple-cloudkit.com/database/1/${CONFIG.cloudKitContainer}/${CONFIG.cloudKitEnvironment}/public/records/lookup`;
+
+ const response = await fetch(ckURL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ records: [{ recordName }]
+ })
+ });
+
+ if (!response.ok) {
+ console.error('CloudKit error:', await response.text());
+ return null;
+ }
+
+ const data = await response.json();
+
+ if (!data.records || data.records.length === 0) {
+ return null;
+ }
+
+ const record = data.records[0];
+
+ // Check if expired
+ if (record.fields?.expiresAt?.value) {
+ const expiresAt = new Date(record.fields.expiresAt.value);
+ if (expiresAt < new Date()) {
+ return null; // Expired
+ }
+ }
+
+ return record.fields?.vCardData?.value || null;
+}
+
+// ============================================================================
+// ERROR PAGES
+// ============================================================================
+
+function handleNotFound() {
+ const html = `
+
+
+
+
+ Card Not Found
+
+
+
+
+
😕
+
Card Not Found
+
This business card may have expired or been removed. Shared cards are available for 7 days.
+
+
+`;
+
+ return new Response(html, {
+ status: 404,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' }
+ });
+}
+
+function handleError() {
+ const html = `
+
+
+
+
+ Error
+
+
+
+
+
⚠️
+
Something Went Wrong
+
We couldn't load this business card. Please try again or ask the sender for a new link.
+
+
+`;
+
+ return new Response(html, {
+ status: 500,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' }
+ });
+}
+
+// ============================================================================
+// LANDING PAGE
+// ============================================================================
+
+function handleLandingPage() {
+ const html = `
+
+
+
+
+ BusinessCard
+
+
+
+
+
📇 BusinessCard
+
Share your digital business card with anyone, even if they don't have the app.
+
+
+`;
+
+ return new Response(html, {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' }
+ });
+}
+```
+
+3. Click **Save and Deploy**
+
+### Step 3.3: Update Configuration Values
+
+In the worker code, update these values at the top:
+
+```javascript
+const CONFIG = {
+ teamId: '6R7KLBPBLZ', // Your Team ID from Apple Developer Portal
+ appClipBundleId: 'com.mbrucedogs.BusinessCard.Clip', // From Base.xcconfig
+ cloudKitContainer: 'iCloud.com.mbrucedogs.BusinessCard', // From Base.xcconfig
+ cloudKitEnvironment: 'production', // or 'development' for testing
+};
+```
+
+### Step 3.4: Add Custom Domain to Worker
+
+1. In the Worker settings, click **Triggers** tab
+2. Click **Add Custom Domain**
+3. Enter your domain (e.g., `cards.yourdomain.com` or just `yourdomain.com`)
+4. Cloudflare will automatically configure DNS
+
+### Step 3.5: Verify AASA File
+
+Open in browser: `https://yourdomain.com/.well-known/apple-app-site-association`
+
+You should see:
+```json
+{"appclips":{"apps":["6R7KLBPBLZ.com.mbrucedogs.BusinessCard.Clip"]}}
+```
+
+---
+
+## Phase 4: Configure App Store Connect (~20 minutes)
+
+### Step 4.1: Register App Clip Bundle ID
+
+1. Go to [Apple Developer Portal](https://developer.apple.com/account)
+2. Click **Certificates, Identifiers & Profiles**
+3. Click **Identifiers** → **+**
+4. Select **App IDs** → **App Clip**
+5. Enter:
+ - Description: `BusinessCard App Clip`
+ - Bundle ID: `com.mbrucedogs.BusinessCard.Clip`
+6. Enable capabilities:
+ - **Associated Domains** ✅
+ - **iCloud** (CloudKit) ✅
+7. Click **Continue** → **Register**
+
+### Step 4.2: Create App Store Connect Record
+
+1. Go to [App Store Connect](https://appstoreconnect.apple.com)
+2. Select your BusinessCard app
+3. Under **App Clip** section (or create if first time)
+4. Configure the App Clip Experience:
+ - **Title**: BusinessCard
+ - **Subtitle**: View & save contact
+ - **Action**: Open
+
+### Step 4.3: Add Invocation URLs
+
+1. In App Clip settings, click **Advanced App Clip Experiences**
+2. Add your URL pattern:
+ - URL: `https://yourdomain.com/appclip`
+ - Action: Open
+
+---
+
+## Phase 5: Update Xcode Project (~10 minutes)
+
+### Step 5.1: Update Base.xcconfig
+
+Open `BusinessCard/Configuration/Base.xcconfig` and update:
+
+```
+APPCLIP_DOMAIN = yourdomain.com
+```
+
+Replace `yourdomain.com` with your actual domain.
+
+### Step 5.2: Build and Upload to TestFlight
+
+1. Select **BusinessCard** scheme (main app - it includes the App Clip)
+2. Product → Archive
+3. Distribute to App Store Connect
+4. Upload to TestFlight
+
+### Step 5.3: Test with TestFlight
+
+1. Install the app via TestFlight on a real device
+2. Open the app → Share tab
+3. Generate an App Clip link for a card
+4. Scan the QR code with another iOS device's camera
+5. App Clip should launch and show the card preview
+
+---
+
+## Phase 6: Testing Checklist
+
+### Local Testing (Simulator)
+
+- [ ] Main app builds successfully
+- [ ] App Clip target builds successfully
+- [ ] Can generate App Clip URL in Share view
+- [ ] CloudKit record is created
+
+### TestFlight Testing
+
+- [ ] App + App Clip installed via TestFlight
+- [ ] QR code scans and App Clip launches
+- [ ] Card preview shows correctly with photo
+- [ ] "Save to Contacts" works
+- [ ] Android device downloads .vcf file from same URL
+
+### Production Readiness
+
+- [ ] AASA file accessible at domain
+- [ ] CloudKit production schema deployed
+- [ ] App Clip Experience configured in App Store Connect
+
+---
+
+## Troubleshooting
+
+### App Clip Doesn't Launch When Scanning QR
+
+1. **Check AASA file**: Visit `https://yourdomain.com/.well-known/apple-app-site-association`
+2. **Validate with Apple**: Use [AASA Validator](https://branch.io/resources/aasa-validator/)
+3. **Check entitlements**: Ensure `appclips:yourdomain.com` is in both app and clip entitlements
+4. **Wait for cache**: Apple caches AASA files; may take up to 24 hours
+
+### CloudKit Fetch Fails
+
+1. **Check environment**: Using `development` vs `production`?
+2. **Check permissions**: Public database needs "World: Read" permission
+3. **Check record exists**: Verify in CloudKit Console
+
+### Android vCard Download Fails
+
+1. **Check Cloudflare Worker logs**: Workers → your-worker → Logs
+2. **Test CloudKit endpoint**: Try the fetch manually in worker
+3. **Check CORS**: Shouldn't be needed for downloads but verify
+
+---
+
+## Summary
+
+| Step | What | Time |
+|------|------|------|
+| 1 | Register domain | 10 min |
+| 2 | CloudKit schema | 15 min |
+| 3 | Cloudflare Worker | 30 min |
+| 4 | App Store Connect | 20 min |
+| 5 | Xcode + TestFlight | 10 min |
+| 6 | Testing | 15 min |
+| **Total** | | **~2 hours** |
+
+**Ongoing Cost**: ~$10-15/year (domain only)
+
+---
+
+*Last updated: January 10, 2026*
diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj
index bb399d9..ef399cd 100644
--- a/BusinessCard.xcodeproj/project.pbxproj
+++ b/BusinessCard.xcodeproj/project.pbxproj
@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; };
EAAE892A2F12DE110075BC8A /* BusinessCardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -33,6 +34,13 @@
remoteGlobalIDString = EA837F972F11B16400077F87;
remoteInfo = "BusinessCardWatch Watch App";
};
+ EACLIP0012F200000000003 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = EA83791B2F105F2600077F87 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = EACLIP0012F200000000004;
+ remoteInfo = BusinessCardClip;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -47,6 +55,17 @@
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
+ EACLIP0012F200000000005 /* Embed App Clips */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "$(CONTENTS_FOLDER_PATH)/AppClips";
+ dstSubfolderSpec = 16;
+ files = (
+ EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */,
+ );
+ name = "Embed App Clips";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -54,10 +73,9 @@
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BusinessCardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
- EACONFIG0012F200000000001 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Base.xcconfig; sourceTree = SOURCE_ROOT; };
- EACONFIG0012F200000000002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
- EACONFIG0012F200000000003 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
- EACONFIG0012F200000000004 /* Watch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "BusinessCardWatch Watch App/Configuration/Watch.xcconfig"; sourceTree = SOURCE_ROOT; };
+ EACLIP0012F200000000002 /* BusinessCardClip.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCardClip.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
+ EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -68,6 +86,13 @@
);
target = EA8379222F105F2600077F87 /* BusinessCard */;
};
+ EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = EACLIP0012F200000000004 /* BusinessCardClip */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -94,6 +119,14 @@
path = "BusinessCardWatch Watch App";
sourceTree = "";
};
+ EACLIP0012F200000000006 /* BusinessCardClip */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */,
+ );
+ path = BusinessCardClip;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -126,6 +159,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ EACLIP0012F200000000007 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -136,8 +176,10 @@
EA8379332F105F2800077F87 /* BusinessCardTests */,
EA83793D2F105F2800077F87 /* BusinessCardUITests */,
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */,
+ EACLIP0012F200000000006 /* BusinessCardClip */,
EAAE89292F12DE110075BC8A /* Frameworks */,
EA8379242F105F2600077F87 /* Products */,
+ EADCDC1C2F12F7EA007991B3 /* Recovered References */,
);
sourceTree = "";
};
@@ -148,6 +190,7 @@
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
+ EACLIP0012F200000000002 /* BusinessCardClip.app */,
);
name = Products;
sourceTree = "";
@@ -159,6 +202,15 @@
name = Frameworks;
sourceTree = "";
};
+ EADCDC1C2F12F7EA007991B3 /* Recovered References */ = {
+ isa = PBXGroup;
+ children = (
+ EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */,
+ EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */,
+ );
+ name = "Recovered References";
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -170,11 +222,13 @@
EA8379202F105F2600077F87 /* Frameworks */,
EA8379212F105F2600077F87 /* Resources */,
EAAE892D2F12DE110075BC8A /* Embed Watch Content */,
+ EACLIP0012F200000000005 /* Embed App Clips */,
);
buildRules = (
);
dependencies = (
EAAE892C2F12DE110075BC8A /* PBXTargetDependency */,
+ EACLIP0012F200000000008 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA8379252F105F2600077F87 /* BusinessCard */,
@@ -255,6 +309,28 @@
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
productType = "com.apple.product-type.application";
};
+ EACLIP0012F200000000004 /* BusinessCardClip */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = EACLIP0012F200000000009 /* Build configuration list for PBXNativeTarget "BusinessCardClip" */;
+ buildPhases = (
+ EACLIP0012F20000000000A /* Sources */,
+ EACLIP0012F200000000007 /* Frameworks */,
+ EACLIP0012F20000000000B /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ EACLIP0012F200000000006 /* BusinessCardClip */,
+ );
+ name = BusinessCardClip;
+ packageProductDependencies = (
+ );
+ productName = BusinessCardClip;
+ productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
+ productType = "com.apple.product-type.application.on-demand-install-capable";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -279,6 +355,9 @@
EA837F972F11B16400077F87 = {
CreatedOnToolsVersion = 26.0;
};
+ EACLIP0012F200000000004 = {
+ CreatedOnToolsVersion = 26.0;
+ };
};
};
buildConfigurationList = EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */;
@@ -304,6 +383,7 @@
EA83792F2F105F2800077F87 /* BusinessCardTests */,
EA8379392F105F2800077F87 /* BusinessCardUITests */,
EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */,
+ EACLIP0012F200000000004 /* BusinessCardClip */,
);
};
/* End PBXProject section */
@@ -337,6 +417,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ EACLIP0012F20000000000B /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -368,6 +455,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ EACLIP0012F20000000000A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -386,12 +480,17 @@
target = EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */;
targetProxy = EAAE892B2F12DE110075BC8A /* PBXContainerItemProxy */;
};
+ EACLIP0012F200000000008 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = EACLIP0012F200000000004 /* BusinessCardClip */;
+ targetProxy = EACLIP0012F200000000003 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
EA8379422F105F2800077F87 /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = EACONFIG0012F200000000002 /* Debug.xcconfig */;
+ baseConfigurationReference = EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -456,7 +555,7 @@
};
EA8379432F105F2800077F87 /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = EACONFIG0012F200000000003 /* Release.xcconfig */;
+ baseConfigurationReference = EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -736,6 +835,80 @@
};
name = Release;
};
+ EACLIP0012F20000000000C /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = BusinessCardClip/BusinessCardClip.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = BusinessCardClip/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = BusinessCard;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 6.2;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ EACLIP0012F20000000000D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = BusinessCardClip/BusinessCardClip.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = BusinessCardClip/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = BusinessCard;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 6.2;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -784,6 +957,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ EACLIP0012F200000000009 /* Build configuration list for PBXNativeTarget "BusinessCardClip" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ EACLIP0012F20000000000C /* Debug */,
+ EACLIP0012F20000000000D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
index f560082..2643284 100644
--- a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -5,6 +5,11 @@
SchemeUserState
BusinessCard.xcscheme_^#shared#^_
+
+ orderHint
+ 2
+
+ BusinessCardClip.xcscheme_^#shared#^_
orderHint
1
@@ -12,7 +17,7 @@
BusinessCardWatch Watch App.xcscheme_^#shared#^_
orderHint
- 2
+ 3
diff --git a/BusinessCard/BusinessCard.entitlements b/BusinessCard/BusinessCard.entitlements
index 114bb29..eda886d 100644
--- a/BusinessCard/BusinessCard.entitlements
+++ b/BusinessCard/BusinessCard.entitlements
@@ -4,6 +4,10 @@
aps-environment
development
+ com.apple.developer.associated-domains
+
+ appclips:$(APPCLIP_DOMAIN)
+
com.apple.developer.icloud-container-identifiers
$(CLOUDKIT_CONTAINER_IDENTIFIER)
diff --git a/BusinessCard/Models/SharedCardRecord.swift b/BusinessCard/Models/SharedCardRecord.swift
new file mode 100644
index 0000000..ae90b22
--- /dev/null
+++ b/BusinessCard/Models/SharedCardRecord.swift
@@ -0,0 +1,44 @@
+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
+ }
+}
diff --git a/BusinessCard/Models/SharedCardUploadResult.swift b/BusinessCard/Models/SharedCardUploadResult.swift
new file mode 100644
index 0000000..2adc493
--- /dev/null
+++ b/BusinessCard/Models/SharedCardUploadResult.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+/// Result of uploading a shared card to CloudKit for App Clip access.
+struct SharedCardUploadResult: Sendable {
+ /// The CloudKit record name (UUID) used to fetch the card.
+ let recordName: String
+
+ /// The URL that invokes the App Clip when scanned.
+ let appClipURL: URL
+
+ /// When the shared card expires and will be deleted.
+ let expiresAt: Date
+}
diff --git a/BusinessCard/Protocols/SharedCardProviding.swift b/BusinessCard/Protocols/SharedCardProviding.swift
new file mode 100644
index 0000000..6f8e63d
--- /dev/null
+++ b/BusinessCard/Protocols/SharedCardProviding.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+/// Provides shared card upload and cleanup functionality for App Clip sharing.
+protocol SharedCardProviding: Sendable {
+ /// Uploads a business card to CloudKit public database for App Clip access.
+ /// - Parameter card: The business card to share.
+ /// - Returns: Upload result containing the App Clip URL and expiration date.
+ @MainActor
+ func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult
+
+ /// Cleans up expired shared cards from CloudKit.
+ /// This is a best-effort operation that does not throw errors.
+ func cleanupExpiredCards() async
+}
diff --git a/BusinessCard/Services/SharedCardCloudKitService.swift b/BusinessCard/Services/SharedCardCloudKitService.swift
new file mode 100644
index 0000000..17b6c47
--- /dev/null
+++ b/BusinessCard/Services/SharedCardCloudKitService.swift
@@ -0,0 +1,96 @@
+import CloudKit
+import Foundation
+
+/// Service for uploading and managing shared cards in CloudKit public database.
+struct SharedCardCloudKitService: SharedCardProviding {
+ private let container: CKContainer
+ private let database: CKDatabase
+ private let ttlDays: Int
+
+ init(
+ containerID: String = AppIdentifiers.cloudKitContainerIdentifier,
+ ttlDays: Int = 7
+ ) {
+ self.container = CKContainer(identifier: containerID)
+ self.database = container.publicCloudDatabase
+ self.ttlDays = ttlDays
+ }
+
+ @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()
+
+ do {
+ _ = try await database.save(record)
+ } catch {
+ throw SharedCardError.uploadFailed(error)
+ }
+
+ guard let appClipURL = AppIdentifiers.appClipURL(recordName: 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
+ }
+ }
+}
+
+// MARK: - Error Types
+
+/// Errors that can occur during shared card operations.
+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")
+ }
+ }
+}
diff --git a/BusinessCard/State/AppClipShareState.swift b/BusinessCard/State/AppClipShareState.swift
new file mode 100644
index 0000000..66e1f07
--- /dev/null
+++ b/BusinessCard/State/AppClipShareState.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+/// State for managing App Clip card sharing.
+/// Handles upload to CloudKit and provides the resulting App Clip URL.
+@MainActor
+@Observable
+final class AppClipShareState {
+ private let service: SharedCardProviding
+
+ var isUploading = false
+ var uploadResult: SharedCardUploadResult?
+ var errorMessage: String?
+
+ /// Returns true if an App Clip URL has been generated.
+ var hasAppClipURL: Bool { uploadResult != nil }
+
+ init(service: SharedCardProviding = SharedCardCloudKitService()) {
+ self.service = service
+ }
+
+ /// Uploads a card to CloudKit for App Clip sharing.
+ /// - Parameter card: The business card to share.
+ 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
+ }
+ }
+
+ /// Resets the state for a new share operation.
+ func reset() {
+ uploadResult = nil
+ errorMessage = nil
+ }
+}
diff --git a/BusinessCard/State/AppState.swift b/BusinessCard/State/AppState.swift
index dfb7d95..7f705fd 100644
--- a/BusinessCard/State/AppState.swift
+++ b/BusinessCard/State/AppState.swift
@@ -14,5 +14,10 @@ final class AppState {
self.cardStore = CardStore(modelContext: modelContext)
self.contactsStore = ContactsStore(modelContext: modelContext)
self.shareLinkService = ShareLinkService()
+
+ // Clean up expired shared cards on launch (best-effort, non-blocking)
+ Task {
+ await SharedCardCloudKitService().cleanupExpiredCards()
+ }
}
}
diff --git a/BusinessCard/Views/ShareCardView.swift b/BusinessCard/Views/ShareCardView.swift
index 88876a9..7525e78 100644
--- a/BusinessCard/Views/ShareCardView.swift
+++ b/BusinessCard/Views/ShareCardView.swift
@@ -18,6 +18,9 @@ struct ShareCardView: View {
@State private var vCardFileURL: URL?
@State private var messageComposeURL: IdentifiableURL?
@State private var mailComposeURL: IdentifiableURL?
+
+ // App Clip share state
+ @State private var appClipState = AppClipShareState()
var body: some View {
NavigationStack {
@@ -29,9 +32,12 @@ struct ShareCardView: View {
ScrollView {
VStack(spacing: Design.Spacing.xLarge) {
if let card = appState.cardStore.selectedCard {
- // QR Code section
+ // QR Code section (vCard, no photo)
QRCodeSection(card: card)
+ // App Clip section (includes photo)
+ AppClipSection(card: card, appClipState: appClipState)
+
// Share options
ShareOptionsSection(
card: card,
@@ -110,6 +116,90 @@ private struct QRCodeSection: View {
}
}
+// MARK: - App Clip Section
+
+private struct AppClipSection: View {
+ let card: BusinessCard
+ @Bindable var appClipState: AppClipShareState
+
+ var body: some View {
+ VStack(spacing: Design.Spacing.large) {
+ // Header
+ HStack {
+ Image(systemName: "app.gift")
+ .font(.headline)
+ .foregroundStyle(Color.ShareSheet.text)
+ Text("App Clip (includes photo)")
+ .font(.headline)
+ .foregroundStyle(Color.ShareSheet.text)
+ }
+
+ // Content based on state
+ if appClipState.isUploading {
+ ProgressView()
+ .tint(Color.ShareSheet.text)
+ .padding(Design.Spacing.xLarge)
+ .accessibilityLabel(Text("Uploading card"))
+ } else if let result = appClipState.uploadResult {
+ // Show QR code for App Clip URL
+ QRCodeView(payload: result.appClipURL.absoluteString)
+ .frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
+ .padding(Design.Spacing.large)
+ .background(Color.white)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+
+ // Expiration notice
+ Text("Expires in 7 days")
+ .font(.caption)
+ .foregroundStyle(Color.ShareSheet.secondaryText)
+
+ // Reset button
+ Button {
+ appClipState.reset()
+ } label: {
+ Text("Generate New Link")
+ .font(.subheadline)
+ .foregroundStyle(Color.ShareSheet.text)
+ }
+ } else {
+ // Generate button
+ Button {
+ Task { await appClipState.shareViaAppClip(card: card) }
+ } label: {
+ HStack(spacing: Design.Spacing.small) {
+ Image(systemName: "qrcode")
+ Text("Generate App Clip Link")
+ }
+ .font(.headline)
+ .foregroundStyle(Color.ShareSheet.background)
+ .padding(.horizontal, Design.Spacing.xLarge)
+ .padding(.vertical, Design.Spacing.medium)
+ .background(Color.ShareSheet.text)
+ .clipShape(.capsule)
+ }
+
+ // Description
+ Text("Creates a link that opens a mini-app for recipients to preview and save your card with photo.")
+ .font(.caption)
+ .foregroundStyle(Color.ShareSheet.secondaryText)
+ .multilineTextAlignment(.center)
+ }
+
+ // Error message
+ if let error = appClipState.errorMessage {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(Design.Spacing.xLarge)
+ .background(Color.ShareSheet.cardBackground)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
+ }
+}
+
// MARK: - Share Options Section
private struct ShareOptionsSection: View {
diff --git a/BusinessCardClip/Assets.xcassets/AccentColor.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..2f505e7
--- /dev/null
+++ b/BusinessCardClip/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.950",
+ "green" : "0.650",
+ "red" : "0.350"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCardClip/Assets.xcassets/AppIcon.appiconset/Contents.json b/BusinessCardClip/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..13613e3
--- /dev/null
+++ b/BusinessCardClip/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCardClip/Assets.xcassets/Contents.json b/BusinessCardClip/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCardClip/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCardClip/Assets.xcassets/LaunchBackground.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/LaunchBackground.colorset/Contents.json
new file mode 100644
index 0000000..8c2b272
--- /dev/null
+++ b/BusinessCardClip/Assets.xcassets/LaunchBackground.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.150",
+ "green" : "0.130",
+ "red" : "0.120"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCardClip/BusinessCardClip.entitlements b/BusinessCardClip/BusinessCardClip.entitlements
new file mode 100644
index 0000000..c058543
--- /dev/null
+++ b/BusinessCardClip/BusinessCardClip.entitlements
@@ -0,0 +1,22 @@
+
+
+
+
+ com.apple.developer.associated-domains
+
+ appclips:$(APPCLIP_DOMAIN)
+
+ com.apple.developer.icloud-container-identifiers
+
+ $(CLOUDKIT_CONTAINER_IDENTIFIER)
+
+ com.apple.developer.icloud-services
+
+ CloudKit
+
+ com.apple.developer.parent-application-identifiers
+
+ $(TeamIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)
+
+
+
diff --git a/BusinessCardClip/BusinessCardClipApp.swift b/BusinessCardClip/BusinessCardClipApp.swift
new file mode 100644
index 0000000..60566e9
--- /dev/null
+++ b/BusinessCardClip/BusinessCardClipApp.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+
+@main
+struct BusinessCardClipApp: App {
+ @State private var recordName: String?
+
+ var body: some Scene {
+ WindowGroup {
+ Group {
+ if let recordName {
+ ClipRootView(recordName: recordName)
+ } else {
+ ClipLoadingView()
+ }
+ }
+ .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
+ handleUserActivity(activity)
+ }
+ .onOpenURL { url in
+ handleURL(url)
+ }
+ }
+ }
+
+ private func handleUserActivity(_ activity: NSUserActivity) {
+ guard let url = activity.webpageURL else { return }
+ handleURL(url)
+ }
+
+ private func handleURL(_ url: URL) {
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
+ let id = components.queryItems?.first(where: { $0.name == "id" })?.value else {
+ return
+ }
+ recordName = id
+ }
+}
diff --git a/BusinessCardClip/Configuration/Clip.xcconfig b/BusinessCardClip/Configuration/Clip.xcconfig
new file mode 100644
index 0000000..10c48aa
--- /dev/null
+++ b/BusinessCardClip/Configuration/Clip.xcconfig
@@ -0,0 +1,8 @@
+// Clip.xcconfig
+// Configuration for App Clip target
+
+#include "../BusinessCard/Configuration/Base.xcconfig"
+
+// App Clip specific settings
+PRODUCT_BUNDLE_IDENTIFIER = $(APPCLIP_BUNDLE_IDENTIFIER)
+INFOPLIST_KEY_CFBundleDisplayName = BusinessCard
diff --git a/BusinessCardClip/Configuration/ClipIdentifiers.swift b/BusinessCardClip/Configuration/ClipIdentifiers.swift
new file mode 100644
index 0000000..89484f1
--- /dev/null
+++ b/BusinessCardClip/Configuration/ClipIdentifiers.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+/// Centralized identifiers for the App Clip.
+/// Reads from Info.plist which gets values from xcconfig at build time.
+///
+/// The source of truth is `BusinessCard/Configuration/Base.xcconfig`.
+/// The App Clip inherits these values through its own xcconfig.
+enum ClipIdentifiers {
+
+ /// CloudKit container identifier.
+ /// Must match the main app's container to access shared cards.
+ static let cloudKitContainerIdentifier: String = {
+ Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
+ ?? "iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)"
+ }()
+
+ /// App Clip domain for URL handling.
+ static let appClipDomain: String = {
+ Bundle.main.object(forInfoDictionaryKey: "AppClipDomain") as? String
+ ?? "cards.example.com"
+ }()
+
+ /// Bundle identifier of the App Clip.
+ static var bundleIdentifier: String {
+ Bundle.main.bundleIdentifier ?? "$(APPCLIP_BUNDLE_IDENTIFIER)"
+ }
+
+ /// Parent app bundle identifier.
+ static var parentBundleIdentifier: String {
+ // Remove ".Clip" suffix to get parent bundle ID
+ let clipSuffix = ".Clip"
+ if bundleIdentifier.hasSuffix(clipSuffix) {
+ return String(bundleIdentifier.dropLast(clipSuffix.count))
+ }
+ return bundleIdentifier
+ }
+}
diff --git a/BusinessCardClip/Design/ClipDesignConstants.swift b/BusinessCardClip/Design/ClipDesignConstants.swift
new file mode 100644
index 0000000..202e9c4
--- /dev/null
+++ b/BusinessCardClip/Design/ClipDesignConstants.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+
+/// Design constants for the App Clip.
+/// Mirrors main app design but with minimal footprint for size constraints.
+enum ClipDesign {
+
+ // MARK: - Spacing
+
+ enum Spacing {
+ static let xSmall: CGFloat = 4
+ static let small: CGFloat = 8
+ static let medium: CGFloat = 12
+ static let large: CGFloat = 16
+ static let xLarge: CGFloat = 24
+ static let xxLarge: CGFloat = 32
+ }
+
+ // MARK: - Corner Radius
+
+ enum CornerRadius {
+ static let small: CGFloat = 8
+ static let medium: CGFloat = 12
+ static let large: CGFloat = 16
+ static let xLarge: CGFloat = 24
+ }
+
+ // MARK: - Sizes
+
+ enum Size {
+ static let avatar: CGFloat = 80
+ static let avatarLarge: CGFloat = 120
+ static let buttonHeight: CGFloat = 50
+ }
+
+ // MARK: - Opacity
+
+ enum Opacity {
+ static let subtle: Double = 0.3
+ static let medium: Double = 0.5
+ static let strong: Double = 0.7
+ }
+}
+
+// MARK: - Colors
+
+extension Color {
+
+ enum Clip {
+ static let background = Color(red: 0.12, green: 0.13, blue: 0.15)
+ static let cardBackground = Color(red: 0.18, green: 0.19, blue: 0.22)
+ static let text = Color(red: 0.96, green: 0.96, blue: 0.97)
+ static let secondaryText = Color(red: 0.70, green: 0.72, blue: 0.75)
+ static let accent = Color(red: 0.35, green: 0.65, blue: 0.95)
+ static let success = Color(red: 0.30, green: 0.75, blue: 0.45)
+ static let error = Color(red: 0.95, green: 0.35, blue: 0.35)
+ }
+}
diff --git a/BusinessCardClip/Info.plist b/BusinessCardClip/Info.plist
new file mode 100644
index 0000000..91c1abb
--- /dev/null
+++ b/BusinessCardClip/Info.plist
@@ -0,0 +1,17 @@
+
+
+
+
+ CloudKitContainerIdentifier
+ $(CLOUDKIT_CONTAINER_IDENTIFIER)
+ AppClipDomain
+ $(APPCLIP_DOMAIN)
+ NSContactsUsageDescription
+ Save this contact to your address book.
+ UILaunchScreen
+
+ UIColorName
+ LaunchBackground
+
+
+
diff --git a/BusinessCardClip/Models/SharedCardSnapshot.swift b/BusinessCardClip/Models/SharedCardSnapshot.swift
new file mode 100644
index 0000000..39a7f24
--- /dev/null
+++ b/BusinessCardClip/Models/SharedCardSnapshot.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+/// Represents a shared card fetched from CloudKit for display in the App Clip.
+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? {
+ // Find line that starts with PHOTO; and contains base64 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)
+ }
+}
diff --git a/BusinessCardClip/Services/ClipCloudKitService.swift b/BusinessCardClip/Services/ClipCloudKitService.swift
new file mode 100644
index 0000000..8df07cb
--- /dev/null
+++ b/BusinessCardClip/Services/ClipCloudKitService.swift
@@ -0,0 +1,34 @@
+import CloudKit
+
+/// Service for fetching shared cards from CloudKit public database.
+struct ClipCloudKitService: Sendable {
+ private let database: CKDatabase
+
+ init(containerID: String = ClipIdentifiers.cloudKitContainerIdentifier) {
+ self.database = CKContainer(identifier: containerID).publicCloudDatabase
+ }
+
+ /// Fetches a shared card from CloudKit by record name.
+ /// - Parameter recordName: The UUID string identifying the record.
+ /// - Returns: A snapshot of the shared card data.
+ 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)
+ }
+}
diff --git a/BusinessCardClip/Services/ClipError.swift b/BusinessCardClip/Services/ClipError.swift
new file mode 100644
index 0000000..e3707a9
--- /dev/null
+++ b/BusinessCardClip/Services/ClipError.swift
@@ -0,0 +1,25 @@
+import Foundation
+
+/// Errors that can occur in the App Clip.
+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")
+ }
+ }
+}
diff --git a/BusinessCardClip/Services/ContactSaveService.swift b/BusinessCardClip/Services/ContactSaveService.swift
new file mode 100644
index 0000000..b228fa4
--- /dev/null
+++ b/BusinessCardClip/Services/ContactSaveService.swift
@@ -0,0 +1,31 @@
+import Contacts
+
+/// Service for saving vCard data to the user's Contacts.
+struct ContactSaveService: Sendable {
+
+ /// Saves the vCard data as a new contact.
+ /// - Parameter vCardData: The vCard string to parse and save.
+ 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
+ }
+ }
+}
diff --git a/BusinessCardClip/State/ClipCardStore.swift b/BusinessCardClip/State/ClipCardStore.swift
new file mode 100644
index 0000000..f8a5adb
--- /dev/null
+++ b/BusinessCardClip/State/ClipCardStore.swift
@@ -0,0 +1,56 @@
+import Foundation
+
+/// State management for the App Clip card display and save flow.
+@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
+
+ /// The currently loaded card snapshot, if any.
+ 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
+ }
+
+ /// Loads a shared card from CloudKit.
+ /// - Parameter recordName: The record name (UUID) to fetch.
+ func load(recordName: String) async {
+ state = .loading
+ do {
+ let snapshot = try await cloudKit.fetchSharedCard(recordName: recordName)
+ state = .loaded(snapshot)
+ } catch {
+ state = .error(error.localizedDescription)
+ }
+ }
+
+ /// Saves the currently loaded card to Contacts.
+ func saveToContacts() async {
+ guard let snapshot else { return }
+ do {
+ try await contactSave.saveContact(vCardData: snapshot.vCardData)
+ state = .saved
+ } catch {
+ state = .error(error.localizedDescription)
+ }
+ }
+}
diff --git a/BusinessCardClip/Views/ClipRootView.swift b/BusinessCardClip/Views/ClipRootView.swift
new file mode 100644
index 0000000..cb109b2
--- /dev/null
+++ b/BusinessCardClip/Views/ClipRootView.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+
+/// Root view for the App Clip that manages the card loading and display flow.
+struct ClipRootView: View {
+ @State private var store = ClipCardStore()
+ let recordName: String
+
+ var body: some View {
+ ZStack {
+ Color.Clip.background
+ .ignoresSafeArea()
+
+ 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)
+ }
+ }
+}
diff --git a/BusinessCardClip/Views/Components/ClipCardPreview.swift b/BusinessCardClip/Views/Components/ClipCardPreview.swift
new file mode 100644
index 0000000..1b2462c
--- /dev/null
+++ b/BusinessCardClip/Views/Components/ClipCardPreview.swift
@@ -0,0 +1,120 @@
+import SwiftUI
+
+/// Displays a preview of the shared card with option to save to Contacts.
+struct ClipCardPreview: View {
+ let snapshot: SharedCardSnapshot
+ let onSave: () -> Void
+
+ var body: some View {
+ VStack(spacing: ClipDesign.Spacing.xLarge) {
+ Spacer()
+
+ // Card content
+ VStack(spacing: ClipDesign.Spacing.large) {
+ // Profile photo or placeholder
+ if let photoData = snapshot.photoData,
+ let uiImage = UIImage(data: photoData) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ .frame(width: ClipDesign.Size.avatarLarge, height: ClipDesign.Size.avatarLarge)
+ .clipShape(.circle)
+ } else {
+ Image(systemName: "person.crop.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: ClipDesign.Size.avatarLarge, height: ClipDesign.Size.avatarLarge)
+ .foregroundStyle(Color.Clip.secondaryText)
+ }
+
+ // Name
+ Text(snapshot.displayName)
+ .font(.title)
+ .bold()
+ .foregroundStyle(Color.Clip.text)
+ .multilineTextAlignment(.center)
+
+ // Role and company
+ if !snapshot.role.isEmpty || !snapshot.company.isEmpty {
+ VStack(spacing: ClipDesign.Spacing.xSmall) {
+ if !snapshot.role.isEmpty {
+ Text(snapshot.role)
+ .font(.headline)
+ .foregroundStyle(Color.Clip.secondaryText)
+ }
+ if !snapshot.company.isEmpty {
+ Text(snapshot.company)
+ .font(.subheadline)
+ .foregroundStyle(Color.Clip.secondaryText)
+ }
+ }
+ }
+ }
+ .padding(ClipDesign.Spacing.xLarge)
+ .frame(maxWidth: .infinity)
+ .background(Color.Clip.cardBackground)
+ .clipShape(.rect(cornerRadius: ClipDesign.CornerRadius.xLarge))
+ .padding(.horizontal, ClipDesign.Spacing.large)
+
+ Spacer()
+
+ // Save button
+ Button(action: onSave) {
+ HStack(spacing: ClipDesign.Spacing.small) {
+ Image(systemName: "person.crop.circle.badge.plus")
+ Text("Save to Contacts")
+ }
+ .font(.headline)
+ .foregroundStyle(Color.Clip.background)
+ .frame(maxWidth: .infinity)
+ .frame(height: ClipDesign.Size.buttonHeight)
+ .background(Color.Clip.accent)
+ .clipShape(.capsule)
+ }
+ .padding(.horizontal, ClipDesign.Spacing.xLarge)
+ .accessibilityLabel(Text("Save \(snapshot.displayName) to contacts"))
+
+ // Get full app prompt
+ Button {
+ openAppStore()
+ } label: {
+ Text("Get the full app")
+ .font(.subheadline)
+ .foregroundStyle(Color.Clip.accent)
+ }
+ .padding(.bottom, ClipDesign.Spacing.large)
+ }
+ }
+
+ private func openAppStore() {
+ // Open App Store page for the full app
+ // Replace with actual App Store URL when available
+ if let url = URL(string: "https://apps.apple.com/app/id1234567890") {
+ UIApplication.shared.open(url)
+ }
+ }
+}
+
+#Preview {
+ ZStack {
+ Color.Clip.background
+ .ignoresSafeArea()
+
+ ClipCardPreview(
+ snapshot: SharedCardSnapshot(
+ recordName: "test",
+ vCardData: """
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Sullivan;Daniel;;;
+ FN:Daniel Sullivan
+ ORG:WR Construction
+ TITLE:Property Developer
+ END:VCARD
+ """
+ )
+ ) {
+ print("Save tapped")
+ }
+ }
+}
diff --git a/BusinessCardClip/Views/Components/ClipErrorView.swift b/BusinessCardClip/Views/Components/ClipErrorView.swift
new file mode 100644
index 0000000..ce84daa
--- /dev/null
+++ b/BusinessCardClip/Views/Components/ClipErrorView.swift
@@ -0,0 +1,54 @@
+import SwiftUI
+
+/// Error state shown when card fetch or save fails.
+struct ClipErrorView: View {
+ let message: String
+ let onRetry: () -> Void
+
+ var body: some View {
+ VStack(spacing: ClipDesign.Spacing.xLarge) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: ClipDesign.Size.avatar, height: ClipDesign.Size.avatar)
+ .foregroundStyle(Color.Clip.error)
+
+ VStack(spacing: ClipDesign.Spacing.small) {
+ Text("Something went wrong")
+ .font(.title2)
+ .bold()
+ .foregroundStyle(Color.Clip.text)
+
+ Text(message)
+ .font(.subheadline)
+ .foregroundStyle(Color.Clip.secondaryText)
+ .multilineTextAlignment(.center)
+ }
+
+ Button(action: onRetry) {
+ Text("Try Again")
+ .font(.headline)
+ .foregroundStyle(Color.Clip.background)
+ .frame(maxWidth: .infinity)
+ .frame(height: ClipDesign.Size.buttonHeight)
+ .background(Color.Clip.accent)
+ .clipShape(.capsule)
+ }
+ .padding(.horizontal, ClipDesign.Spacing.xLarge)
+ .padding(.top, ClipDesign.Spacing.large)
+ }
+ .padding(ClipDesign.Spacing.xLarge)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(Text("Error: \(message)"))
+ }
+}
+
+#Preview {
+ ZStack {
+ Color.Clip.background
+ .ignoresSafeArea()
+ ClipErrorView(message: "This card has expired") {
+ print("Retry tapped")
+ }
+ }
+}
diff --git a/BusinessCardClip/Views/Components/ClipLoadingView.swift b/BusinessCardClip/Views/Components/ClipLoadingView.swift
new file mode 100644
index 0000000..49c2a24
--- /dev/null
+++ b/BusinessCardClip/Views/Components/ClipLoadingView.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+
+/// Loading state view shown while fetching the card from CloudKit.
+struct ClipLoadingView: View {
+ var body: some View {
+ VStack(spacing: ClipDesign.Spacing.large) {
+ ProgressView()
+ .scaleEffect(1.5)
+ .tint(Color.Clip.accent)
+
+ Text("Loading card...")
+ .font(.headline)
+ .foregroundStyle(Color.Clip.text)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(Text("Loading business card"))
+ }
+}
+
+#Preview {
+ ZStack {
+ Color.Clip.background
+ .ignoresSafeArea()
+ ClipLoadingView()
+ }
+}
diff --git a/BusinessCardClip/Views/Components/ClipSuccessView.swift b/BusinessCardClip/Views/Components/ClipSuccessView.swift
new file mode 100644
index 0000000..27c885c
--- /dev/null
+++ b/BusinessCardClip/Views/Components/ClipSuccessView.swift
@@ -0,0 +1,67 @@
+import SwiftUI
+
+/// Success state shown after the contact has been saved.
+struct ClipSuccessView: View {
+ @State private var showCheckmark = false
+
+ var body: some View {
+ VStack(spacing: ClipDesign.Spacing.xLarge) {
+ // Animated checkmark
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: ClipDesign.Size.avatarLarge, height: ClipDesign.Size.avatarLarge)
+ .foregroundStyle(Color.Clip.success)
+ .scaleEffect(showCheckmark ? 1 : 0.5)
+ .opacity(showCheckmark ? 1 : 0)
+ .animation(.spring(response: 0.5, dampingFraction: 0.6), value: showCheckmark)
+
+ VStack(spacing: ClipDesign.Spacing.small) {
+ Text("Contact Saved!")
+ .font(.title2)
+ .bold()
+ .foregroundStyle(Color.Clip.text)
+
+ Text("You can find this contact in your Contacts app.")
+ .font(.subheadline)
+ .foregroundStyle(Color.Clip.secondaryText)
+ .multilineTextAlignment(.center)
+ }
+
+ // Open Contacts button
+ Button {
+ openContacts()
+ } label: {
+ Text("Open Contacts")
+ .font(.headline)
+ .foregroundStyle(Color.Clip.background)
+ .frame(maxWidth: .infinity)
+ .frame(height: ClipDesign.Size.buttonHeight)
+ .background(Color.Clip.success)
+ .clipShape(.capsule)
+ }
+ .padding(.horizontal, ClipDesign.Spacing.xLarge)
+ .padding(.top, ClipDesign.Spacing.large)
+ }
+ .padding(ClipDesign.Spacing.xLarge)
+ .onAppear {
+ showCheckmark = true
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(Text("Contact saved successfully"))
+ }
+
+ private func openContacts() {
+ if let url = URL(string: "contacts://") {
+ UIApplication.shared.open(url)
+ }
+ }
+}
+
+#Preview {
+ ZStack {
+ Color.Clip.background
+ .ignoresSafeArea()
+ ClipSuccessView()
+ }
+}