Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
0abf25c876
commit
44724914b4
654
APPCLIP-DEPLOYMENT.md
Normal file
654
APPCLIP-DEPLOYMENT.md
Normal file
@ -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 = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="apple-itunes-app" content="app-clip-bundle-id=${CONFIG.appClipBundleId}">
|
||||||
|
<title>Opening BusinessCard...</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.container { max-width: 400px; }
|
||||||
|
h1 { font-size: 24px; margin-bottom: 16px; }
|
||||||
|
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
|
||||||
|
.loader {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(255,255,255,0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 24px auto;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<h1>Opening BusinessCard</h1>
|
||||||
|
<p>If the app doesn't open automatically, make sure you have iOS 14 or later.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
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 = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Card Not Found</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.container { max-width: 400px; }
|
||||||
|
h1 { font-size: 48px; margin-bottom: 8px; }
|
||||||
|
h2 { font-size: 24px; margin-bottom: 16px; font-weight: 500; }
|
||||||
|
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>😕</h1>
|
||||||
|
<h2>Card Not Found</h2>
|
||||||
|
<p>This business card may have expired or been removed. Shared cards are available for 7 days.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError() {
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Error</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.container { max-width: 400px; }
|
||||||
|
h1 { font-size: 48px; margin-bottom: 8px; }
|
||||||
|
h2 { font-size: 24px; margin-bottom: 16px; font-weight: 500; }
|
||||||
|
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>⚠️</h1>
|
||||||
|
<h2>Something Went Wrong</h2>
|
||||||
|
<p>We couldn't load this business card. Please try again or ask the sender for a new link.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LANDING PAGE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function handleLandingPage() {
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BusinessCard</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.container { max-width: 400px; }
|
||||||
|
h1 { font-size: 32px; margin-bottom: 16px; }
|
||||||
|
p { font-size: 18px; opacity: 0.8; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📇 BusinessCard</h1>
|
||||||
|
<p>Share your digital business card with anyone, even if they don't have the app.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
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*
|
||||||
@ -9,6 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; };
|
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, ); }; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -33,6 +34,13 @@
|
|||||||
remoteGlobalIDString = EA837F972F11B16400077F87;
|
remoteGlobalIDString = EA837F972F11B16400077F87;
|
||||||
remoteInfo = "BusinessCardWatch Watch App";
|
remoteInfo = "BusinessCardWatch Watch App";
|
||||||
};
|
};
|
||||||
|
EACLIP0012F200000000003 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = EA83791B2F105F2600077F87 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = EACLIP0012F200000000004;
|
||||||
|
remoteInfo = BusinessCardClip;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@ -47,6 +55,17 @@
|
|||||||
name = "Embed Watch Content";
|
name = "Embed Watch Content";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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; };
|
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; };
|
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; };
|
EACLIP0012F200000000002 /* BusinessCardClip.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCardClip.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EACONFIG0012F200000000002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
EACONFIG0012F200000000002 /* BusinessCard/Configuration/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; };
|
EACONFIG0012F200000000003 /* BusinessCard/Configuration/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; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -68,6 +86,13 @@
|
|||||||
);
|
);
|
||||||
target = EA8379222F105F2600077F87 /* BusinessCard */;
|
target = EA8379222F105F2600077F87 /* BusinessCard */;
|
||||||
};
|
};
|
||||||
|
EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = EACLIP0012F200000000004 /* BusinessCardClip */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@ -94,6 +119,14 @@
|
|||||||
path = "BusinessCardWatch Watch App";
|
path = "BusinessCardWatch Watch App";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
EACLIP0012F200000000006 /* BusinessCardClip */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */,
|
||||||
|
);
|
||||||
|
path = BusinessCardClip;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -126,6 +159,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
EACLIP0012F200000000007 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@ -136,8 +176,10 @@
|
|||||||
EA8379332F105F2800077F87 /* BusinessCardTests */,
|
EA8379332F105F2800077F87 /* BusinessCardTests */,
|
||||||
EA83793D2F105F2800077F87 /* BusinessCardUITests */,
|
EA83793D2F105F2800077F87 /* BusinessCardUITests */,
|
||||||
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */,
|
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */,
|
||||||
|
EACLIP0012F200000000006 /* BusinessCardClip */,
|
||||||
EAAE89292F12DE110075BC8A /* Frameworks */,
|
EAAE89292F12DE110075BC8A /* Frameworks */,
|
||||||
EA8379242F105F2600077F87 /* Products */,
|
EA8379242F105F2600077F87 /* Products */,
|
||||||
|
EADCDC1C2F12F7EA007991B3 /* Recovered References */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -148,6 +190,7 @@
|
|||||||
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
|
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
|
||||||
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
|
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
|
||||||
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
|
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
|
||||||
|
EACLIP0012F200000000002 /* BusinessCardClip.app */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -159,6 +202,15 @@
|
|||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
EADCDC1C2F12F7EA007991B3 /* Recovered References */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */,
|
||||||
|
EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */,
|
||||||
|
);
|
||||||
|
name = "Recovered References";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@ -170,11 +222,13 @@
|
|||||||
EA8379202F105F2600077F87 /* Frameworks */,
|
EA8379202F105F2600077F87 /* Frameworks */,
|
||||||
EA8379212F105F2600077F87 /* Resources */,
|
EA8379212F105F2600077F87 /* Resources */,
|
||||||
EAAE892D2F12DE110075BC8A /* Embed Watch Content */,
|
EAAE892D2F12DE110075BC8A /* Embed Watch Content */,
|
||||||
|
EACLIP0012F200000000005 /* Embed App Clips */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
EAAE892C2F12DE110075BC8A /* PBXTargetDependency */,
|
EAAE892C2F12DE110075BC8A /* PBXTargetDependency */,
|
||||||
|
EACLIP0012F200000000008 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
EA8379252F105F2600077F87 /* BusinessCard */,
|
EA8379252F105F2600077F87 /* BusinessCard */,
|
||||||
@ -255,6 +309,28 @@
|
|||||||
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
|
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@ -279,6 +355,9 @@
|
|||||||
EA837F972F11B16400077F87 = {
|
EA837F972F11B16400077F87 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
};
|
};
|
||||||
|
EACLIP0012F200000000004 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */;
|
buildConfigurationList = EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */;
|
||||||
@ -304,6 +383,7 @@
|
|||||||
EA83792F2F105F2800077F87 /* BusinessCardTests */,
|
EA83792F2F105F2800077F87 /* BusinessCardTests */,
|
||||||
EA8379392F105F2800077F87 /* BusinessCardUITests */,
|
EA8379392F105F2800077F87 /* BusinessCardUITests */,
|
||||||
EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */,
|
EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */,
|
||||||
|
EACLIP0012F200000000004 /* BusinessCardClip */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@ -337,6 +417,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
EACLIP0012F20000000000B /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@ -368,6 +455,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
EACLIP0012F20000000000A /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@ -386,12 +480,17 @@
|
|||||||
target = EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */;
|
target = EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */;
|
||||||
targetProxy = EAAE892B2F12DE110075BC8A /* PBXContainerItemProxy */;
|
targetProxy = EAAE892B2F12DE110075BC8A /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
EACLIP0012F200000000008 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = EACLIP0012F200000000004 /* BusinessCardClip */;
|
||||||
|
targetProxy = EACLIP0012F200000000003 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
EA8379422F105F2800077F87 /* Debug */ = {
|
EA8379422F105F2800077F87 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = EACONFIG0012F200000000002 /* Debug.xcconfig */;
|
baseConfigurationReference = EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
@ -456,7 +555,7 @@
|
|||||||
};
|
};
|
||||||
EA8379432F105F2800077F87 /* Release */ = {
|
EA8379432F105F2800077F87 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = EACONFIG0012F200000000003 /* Release.xcconfig */;
|
baseConfigurationReference = EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
@ -736,6 +835,80 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@ -784,6 +957,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
EACLIP0012F200000000009 /* Build configuration list for PBXNativeTarget "BusinessCardClip" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
EACLIP0012F20000000000C /* Debug */,
|
||||||
|
EACLIP0012F20000000000D /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
|||||||
@ -5,6 +5,11 @@
|
|||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>BusinessCard.xcscheme_^#shared#^_</key>
|
<key>BusinessCard.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
</dict>
|
||||||
|
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
@ -12,7 +17,7 @@
|
|||||||
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>2</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>appclips:$(APPCLIP_DOMAIN)</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||||
|
|||||||
44
BusinessCard/Models/SharedCardRecord.swift
Normal file
44
BusinessCard/Models/SharedCardRecord.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
13
BusinessCard/Models/SharedCardUploadResult.swift
Normal file
13
BusinessCard/Models/SharedCardUploadResult.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
14
BusinessCard/Protocols/SharedCardProviding.swift
Normal file
14
BusinessCard/Protocols/SharedCardProviding.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
96
BusinessCard/Services/SharedCardCloudKitService.swift
Normal file
96
BusinessCard/Services/SharedCardCloudKitService.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
BusinessCard/State/AppClipShareState.swift
Normal file
42
BusinessCard/State/AppClipShareState.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,5 +14,10 @@ final class AppState {
|
|||||||
self.cardStore = CardStore(modelContext: modelContext)
|
self.cardStore = CardStore(modelContext: modelContext)
|
||||||
self.contactsStore = ContactsStore(modelContext: modelContext)
|
self.contactsStore = ContactsStore(modelContext: modelContext)
|
||||||
self.shareLinkService = ShareLinkService()
|
self.shareLinkService = ShareLinkService()
|
||||||
|
|
||||||
|
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
||||||
|
Task {
|
||||||
|
await SharedCardCloudKitService().cleanupExpiredCards()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,9 @@ struct ShareCardView: View {
|
|||||||
@State private var messageComposeURL: IdentifiableURL?
|
@State private var messageComposeURL: IdentifiableURL?
|
||||||
@State private var mailComposeURL: IdentifiableURL?
|
@State private var mailComposeURL: IdentifiableURL?
|
||||||
|
|
||||||
|
// App Clip share state
|
||||||
|
@State private var appClipState = AppClipShareState()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -29,9 +32,12 @@ struct ShareCardView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: Design.Spacing.xLarge) {
|
VStack(spacing: Design.Spacing.xLarge) {
|
||||||
if let card = appState.cardStore.selectedCard {
|
if let card = appState.cardStore.selectedCard {
|
||||||
// QR Code section
|
// QR Code section (vCard, no photo)
|
||||||
QRCodeSection(card: card)
|
QRCodeSection(card: card)
|
||||||
|
|
||||||
|
// App Clip section (includes photo)
|
||||||
|
AppClipSection(card: card, appClipState: appClipState)
|
||||||
|
|
||||||
// Share options
|
// Share options
|
||||||
ShareOptionsSection(
|
ShareOptionsSection(
|
||||||
card: card,
|
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
|
// MARK: - Share Options Section
|
||||||
|
|
||||||
private struct ShareOptionsSection: View {
|
private struct ShareOptionsSection: View {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
BusinessCardClip/Assets.xcassets/Contents.json
Normal file
6
BusinessCardClip/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
22
BusinessCardClip/BusinessCardClip.entitlements
Normal file
22
BusinessCardClip/BusinessCardClip.entitlements
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>appclips:$(APPCLIP_DOMAIN)</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.parent-application-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
37
BusinessCardClip/BusinessCardClipApp.swift
Normal file
37
BusinessCardClip/BusinessCardClipApp.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
8
BusinessCardClip/Configuration/Clip.xcconfig
Normal file
8
BusinessCardClip/Configuration/Clip.xcconfig
Normal file
@ -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
|
||||||
37
BusinessCardClip/Configuration/ClipIdentifiers.swift
Normal file
37
BusinessCardClip/Configuration/ClipIdentifiers.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
57
BusinessCardClip/Design/ClipDesignConstants.swift
Normal file
57
BusinessCardClip/Design/ClipDesignConstants.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
BusinessCardClip/Info.plist
Normal file
17
BusinessCardClip/Info.plist
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CloudKitContainerIdentifier</key>
|
||||||
|
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||||
|
<key>AppClipDomain</key>
|
||||||
|
<string>$(APPCLIP_DOMAIN)</string>
|
||||||
|
<key>NSContactsUsageDescription</key>
|
||||||
|
<string>Save this contact to your address book.</string>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchBackground</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
40
BusinessCardClip/Models/SharedCardSnapshot.swift
Normal file
40
BusinessCardClip/Models/SharedCardSnapshot.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
BusinessCardClip/Services/ClipCloudKitService.swift
Normal file
34
BusinessCardClip/Services/ClipCloudKitService.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
BusinessCardClip/Services/ClipError.swift
Normal file
25
BusinessCardClip/Services/ClipError.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
BusinessCardClip/Services/ContactSaveService.swift
Normal file
31
BusinessCardClip/Services/ContactSaveService.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
BusinessCardClip/State/ClipCardStore.swift
Normal file
56
BusinessCardClip/State/ClipCardStore.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
BusinessCardClip/Views/ClipRootView.swift
Normal file
34
BusinessCardClip/Views/ClipRootView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
BusinessCardClip/Views/Components/ClipCardPreview.swift
Normal file
120
BusinessCardClip/Views/Components/ClipCardPreview.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
BusinessCardClip/Views/Components/ClipErrorView.swift
Normal file
54
BusinessCardClip/Views/Components/ClipErrorView.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
BusinessCardClip/Views/Components/ClipLoadingView.swift
Normal file
26
BusinessCardClip/Views/Components/ClipLoadingView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
67
BusinessCardClip/Views/Components/ClipSuccessView.swift
Normal file
67
BusinessCardClip/Views/Components/ClipSuccessView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user