From 44724914b4d36724b27f9cf0586268433151cba1 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sat, 10 Jan 2026 16:11:58 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- APPCLIP-DEPLOYMENT.md | 654 ++++++++++++++++++ BusinessCard.xcodeproj/project.pbxproj | 194 +++++- .../xcschemes/xcschememanagement.plist | 7 +- BusinessCard/BusinessCard.entitlements | 4 + BusinessCard/Models/SharedCardRecord.swift | 44 ++ .../Models/SharedCardUploadResult.swift | 13 + .../Protocols/SharedCardProviding.swift | 14 + .../Services/SharedCardCloudKitService.swift | 96 +++ BusinessCard/State/AppClipShareState.swift | 42 ++ BusinessCard/State/AppState.swift | 5 + BusinessCard/Views/ShareCardView.swift | 92 ++- .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../LaunchBackground.colorset/Contents.json | 20 + .../BusinessCardClip.entitlements | 22 + BusinessCardClip/BusinessCardClipApp.swift | 37 + BusinessCardClip/Configuration/Clip.xcconfig | 8 + .../Configuration/ClipIdentifiers.swift | 37 + .../Design/ClipDesignConstants.swift | 57 ++ BusinessCardClip/Info.plist | 17 + .../Models/SharedCardSnapshot.swift | 40 ++ .../Services/ClipCloudKitService.swift | 34 + BusinessCardClip/Services/ClipError.swift | 25 + .../Services/ContactSaveService.swift | 31 + BusinessCardClip/State/ClipCardStore.swift | 56 ++ BusinessCardClip/Views/ClipRootView.swift | 34 + .../Views/Components/ClipCardPreview.swift | 120 ++++ .../Views/Components/ClipErrorView.swift | 54 ++ .../Views/Components/ClipLoadingView.swift | 26 + .../Views/Components/ClipSuccessView.swift | 67 ++ 31 files changed, 1881 insertions(+), 8 deletions(-) create mode 100644 APPCLIP-DEPLOYMENT.md create mode 100644 BusinessCard/Models/SharedCardRecord.swift create mode 100644 BusinessCard/Models/SharedCardUploadResult.swift create mode 100644 BusinessCard/Protocols/SharedCardProviding.swift create mode 100644 BusinessCard/Services/SharedCardCloudKitService.swift create mode 100644 BusinessCard/State/AppClipShareState.swift create mode 100644 BusinessCardClip/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 BusinessCardClip/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 BusinessCardClip/Assets.xcassets/Contents.json create mode 100644 BusinessCardClip/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 BusinessCardClip/BusinessCardClip.entitlements create mode 100644 BusinessCardClip/BusinessCardClipApp.swift create mode 100644 BusinessCardClip/Configuration/Clip.xcconfig create mode 100644 BusinessCardClip/Configuration/ClipIdentifiers.swift create mode 100644 BusinessCardClip/Design/ClipDesignConstants.swift create mode 100644 BusinessCardClip/Info.plist create mode 100644 BusinessCardClip/Models/SharedCardSnapshot.swift create mode 100644 BusinessCardClip/Services/ClipCloudKitService.swift create mode 100644 BusinessCardClip/Services/ClipError.swift create mode 100644 BusinessCardClip/Services/ContactSaveService.swift create mode 100644 BusinessCardClip/State/ClipCardStore.swift create mode 100644 BusinessCardClip/Views/ClipRootView.swift create mode 100644 BusinessCardClip/Views/Components/ClipCardPreview.swift create mode 100644 BusinessCardClip/Views/Components/ClipErrorView.swift create mode 100644 BusinessCardClip/Views/Components/ClipLoadingView.swift create mode 100644 BusinessCardClip/Views/Components/ClipSuccessView.swift 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() + } +}