diff --git a/BusinessCard.code-workspace b/BusinessCard.code-workspace
new file mode 100644
index 0000000..6c9d254
--- /dev/null
+++ b/BusinessCard.code-workspace
@@ -0,0 +1,11 @@
+{
+ "folders": [
+ {
+ "path": "."
+ },
+ {
+ "path": "../_Packages/Bedrock"
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file
diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj
index 0fcea3d..17fd0ef 100644
--- a/BusinessCard.xcodeproj/project.pbxproj
+++ b/BusinessCard.xcodeproj/project.pbxproj
@@ -8,7 +8,9 @@
/* Begin PBXBuildFile section */
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; };
- EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69E9262F3D4B5700592220 /* Bedrock */; };
+ EA7568D32F4639EE006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D22F4639EE006196BB /* Bedrock */; };
+ EA7568D52F463A28006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D42F463A28006196BB /* Bedrock */; };
+ EA7568D72F463A2E006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D62F463A2E006196BB /* 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 = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -150,6 +152,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ EA7568D32F4639EE006196BB /* Bedrock in Frameworks */,
EA837E672F107D6800077F87 /* Bedrock in Frameworks */,
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */,
);
@@ -173,6 +176,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ EA7568D72F463A2E006196BB /* Bedrock in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -180,7 +184,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */,
+ EA7568D52F463A28006196BB /* Bedrock in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -255,6 +259,7 @@
packageProductDependencies = (
EA837E662F107D6800077F87 /* Bedrock */,
EA69DC812F3C199C00592220 /* Bedrock */,
+ EA7568D22F4639EE006196BB /* Bedrock */,
);
productName = BusinessCard;
productReference = EA8379232F105F2600077F87 /* Business Card.app */;
@@ -323,6 +328,7 @@
);
name = "BusinessCardWatch Watch App";
packageProductDependencies = (
+ EA7568D62F463A2E006196BB /* Bedrock */,
);
productName = "BusinessCardWatch Watch App";
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
@@ -345,7 +351,7 @@
);
name = BusinessCardClip;
packageProductDependencies = (
- EA69E9262F3D4B5700592220 /* Bedrock */,
+ EA7568D42F463A28006196BB /* Bedrock */,
);
productName = BusinessCardClip;
productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
@@ -392,7 +398,7 @@
mainGroup = EA83791A2F105F2600077F87;
minimizedProjectReferenceProxies = 1;
packageReferences = (
- EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */,
+ EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = EA8379242F105F2600077F87 /* Products */;
@@ -1001,9 +1007,9 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
- EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */ = {
+ EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */ = {
isa = XCLocalSwiftPackageReference;
- relativePath = ../Bedrock;
+ relativePath = ../_Packages/Bedrock;
};
/* End XCLocalSwiftPackageReference section */
@@ -1012,9 +1018,18 @@
isa = XCSwiftPackageProductDependency;
productName = Bedrock;
};
- EA69E9262F3D4B5700592220 /* Bedrock */ = {
+ EA7568D22F4639EE006196BB /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
- package = EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */;
+ productName = Bedrock;
+ };
+ EA7568D42F463A28006196BB /* Bedrock */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */;
+ productName = Bedrock;
+ };
+ EA7568D62F463A2E006196BB /* Bedrock */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */;
productName = Bedrock;
};
EA837E662F107D6800077F87 /* Bedrock */ = {
diff --git a/BusinessCard.xcodeproj/xcshareddata/xcschemes/BusinessCardClip.xcscheme b/BusinessCard.xcodeproj/xcshareddata/xcschemes/BusinessCardClip.xcscheme
index bc3a632..a8b3bda 100644
--- a/BusinessCard.xcodeproj/xcshareddata/xcschemes/BusinessCardClip.xcscheme
+++ b/BusinessCard.xcodeproj/xcshareddata/xcschemes/BusinessCardClip.xcscheme
@@ -53,7 +53,7 @@
+ isEnabled = "NO">
+
+
+
+
BusinessCard.xcscheme_^#shared#^_
orderHint
- 2
+ 1
BusinessCardClip.xcscheme_^#shared#^_
@@ -17,7 +17,7 @@
BusinessCardWatch Watch App.xcscheme_^#shared#^_
orderHint
- 3
+ 2
SuppressBuildableAutocreation
diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings
index 31eeb53..00b8d73 100644
--- a/BusinessCard/Resources/Localizable.xcstrings
+++ b/BusinessCard/Resources/Localizable.xcstrings
@@ -817,6 +817,10 @@
}
}
},
+ "Show first-run onboarding again on next app launch" : {
+ "comment" : "A description of the reset onboarding feature.",
+ "isCommentAutoGenerated" : true
+ },
"Social Media" : {
},
diff --git a/BusinessCardClip/Assets.xcassets/ClipAccent.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/ClipAccent.colorset/Contents.json
index c2d07cf..2a53582 100644
--- a/BusinessCardClip/Assets.xcassets/ClipAccent.colorset/Contents.json
+++ b/BusinessCardClip/Assets.xcassets/ClipAccent.colorset/Contents.json
@@ -1,38 +1,38 @@
{
- "colors" : [
+ "colors": [
{
- "color" : {
- "color-space" : "srgb",
- "components" : {
- "alpha" : "1.000",
- "blue" : "0.860",
- "green" : "0.470",
- "red" : "0.220"
+ "color": {
+ "color-space": "srgb",
+ "components": {
+ "alpha": "1.000",
+ "blue": "0.280",
+ "green": "0.330",
+ "red": "0.950"
}
},
- "idiom" : "universal"
+ "idiom": "universal"
},
{
- "appearances" : [
+ "appearances": [
{
- "appearance" : "luminosity",
- "value" : "dark"
+ "appearance": "luminosity",
+ "value": "dark"
}
],
- "color" : {
- "color-space" : "srgb",
- "components" : {
- "alpha" : "1.000",
- "blue" : "0.950",
- "green" : "0.650",
- "red" : "0.350"
+ "color": {
+ "color-space": "srgb",
+ "components": {
+ "alpha": "1.000",
+ "blue": "0.280",
+ "green": "0.330",
+ "red": "0.950"
}
},
- "idiom" : "universal"
+ "idiom": "universal"
}
],
- "info" : {
- "author" : "xcode",
- "version" : 1
+ "info": {
+ "author": "xcode",
+ "version": 1
}
}
diff --git a/BusinessCardClip/BusinessCardClipApp.swift b/BusinessCardClip/BusinessCardClipApp.swift
index 1ef2fda..900db00 100644
--- a/BusinessCardClip/BusinessCardClipApp.swift
+++ b/BusinessCardClip/BusinessCardClipApp.swift
@@ -1,5 +1,6 @@
import SwiftUI
import AppIntents
+import Bedrock
@main
struct BusinessCardClipApp: App {
@@ -37,30 +38,50 @@ struct BusinessCardClipApp: App {
#endif
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
+ Design.debugLog("Clip: onContinueUserActivity fired, webpageURL=\(activity.webpageURL?.absoluteString ?? "nil")")
handleUserActivity(activity)
}
.onOpenURL { url in
+ Design.debugLog("Clip: onOpenURL fired, url=\(url.absoluteString)")
handleURL(url)
}
.task {
+ Design.debugLog("Clip: .task running - checking launch sources")
#if DEBUG
- debugState = parseDebugStateArgument()
+ if let dbg = parseDebugStateArgument() {
+ Design.debugLog("Clip: debug state from args: \(dbg.rawValue)")
+ debugState = dbg
+ return
+ }
#endif
+ if let envURL = ProcessInfo.processInfo.environment["_XCAppClipURL"],
+ let url = URL(string: envURL) {
+ Design.debugLog("Clip: _XCAppClipURL from env: \(envURL)")
+ handleURL(url)
+ } else {
+ Design.debugLog("Clip: no _XCAppClipURL in env, no debug args. recordName=\(recordName ?? "nil")")
+ }
}
}
}
private func handleUserActivity(_ activity: NSUserActivity) {
- guard let url = activity.webpageURL else { return }
+ Design.debugLog("Clip: handleUserActivity, webpageURL=\(activity.webpageURL?.absoluteString ?? "nil")")
+ guard let url = activity.webpageURL else {
+ Design.debugLog("Clip: handleUserActivity - no webpageURL, skipping")
+ return
+ }
handleURL(url)
}
private func handleURL(_ url: URL) {
+ Design.debugLog("Clip: handleURL \(url.absoluteString)")
guard let id = extractRecordName(from: url) else {
+ Design.debugLog("Clip: extractRecordName failed for \(url.absoluteString)")
launchErrorMessage = ClipError.invalidRecord.localizedDescription
return
}
-
+ Design.debugLog("Clip: extracted recordName=\(id), setting recordName")
launchErrorMessage = nil
recordName = id
}
diff --git a/BusinessCardClip/State/ClipCardStore.swift b/BusinessCardClip/State/ClipCardStore.swift
index 82febb2..056c17e 100644
--- a/BusinessCardClip/State/ClipCardStore.swift
+++ b/BusinessCardClip/State/ClipCardStore.swift
@@ -43,18 +43,25 @@ final class ClipCardStore {
}
}
- /// Saves the currently loaded card to Contacts.
+ /// Saves the currently loaded card to Contacts via CNContactStore.
+ /// Note: App Clips cannot use CNContactStore; use the share sheet flow instead.
func saveToContacts() async {
guard let snapshot else { return }
do {
try await contactSave.saveContact(vCardData: snapshot.vCardData)
- // Best-effort cleanup so saved cards do not linger in public CloudKit.
try? await cloudKit.deleteSharedCard(recordName: snapshot.recordName)
state = .saved
} catch {
state = .error(userMessage(for: error, fallback: ClipError.contactSaveFailed))
}
}
+
+ /// Best-effort cleanup after user shares the vCard (e.g. via share sheet).
+ /// App Clips use the share sheet instead of CNContactStore.
+ func cleanupAfterShare() async {
+ guard let snapshot else { return }
+ try? await cloudKit.deleteSharedCard(recordName: snapshot.recordName)
+ }
private func userMessage(for error: Error, fallback: ClipError) -> String {
if let clipError = error as? ClipError {
diff --git a/BusinessCardClip/Views/ClipRootView.swift b/BusinessCardClip/Views/ClipRootView.swift
index 9e99704..2146128 100644
--- a/BusinessCardClip/Views/ClipRootView.swift
+++ b/BusinessCardClip/Views/ClipRootView.swift
@@ -16,7 +16,7 @@ struct ClipRootView: View {
ClipLoadingView()
case .loaded(let snapshot):
ClipCardPreview(snapshot: snapshot) {
- Task { await store.saveToContacts() }
+ Task { await store.cleanupAfterShare() }
}
case .saved:
ClipSuccessView()
diff --git a/BusinessCardClip/Views/Components/ClipCardPreview.swift b/BusinessCardClip/Views/Components/ClipCardPreview.swift
index 97a2802..538f7b9 100644
--- a/BusinessCardClip/Views/Components/ClipCardPreview.swift
+++ b/BusinessCardClip/Views/Components/ClipCardPreview.swift
@@ -9,24 +9,25 @@ struct ClipCardPreview: View {
}
let snapshot: SharedCardSnapshot
+ /// Called when share sheet is dismissed; use for CloudKit cleanup.
let onSave: () -> Void
+ @State private var showShareSheet = false
+
var body: some View {
- VStack(spacing: ClipDesign.Spacing.xLarge) {
- Spacer(minLength: ClipDesign.Spacing.medium)
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: ClipDesign.Spacing.xLarge) {
+ previewCard
+ .frame(maxWidth: ClipDesign.Size.previewMaxWidth)
- previewCard
- .frame(maxWidth: ClipDesign.Size.previewMaxWidth)
- .padding(.horizontal, ClipDesign.Spacing.large)
-
- VStack(spacing: ClipDesign.Spacing.medium) {
- // Save button
+ VStack(spacing: ClipDesign.Spacing.medium) {
+ // Add to Contacts - uses share sheet because App Clips cannot use CNContactStore
ClipPrimaryButton(
- title: String(localized: "Save to Contacts"),
+ title: String(localized: "Add to Contacts"),
systemImage: "person.crop.circle.badge.plus",
- action: onSave
+ action: { showShareSheet = true }
)
- .accessibilityLabel(Text("Save \(snapshot.displayName) to contacts"))
+ .accessibilityLabel(Text("Add \(snapshot.displayName) to contacts"))
// Get full app prompt
Button {
@@ -36,10 +37,16 @@ struct ClipCardPreview: View {
.styled(.subheading)
.foregroundStyle(Color.Clip.accent)
}
+ }
+ .padding(.horizontal, ClipDesign.Spacing.xLarge)
+ }
+ .padding(.horizontal, ClipDesign.Spacing.large)
+ }
+ .sheet(isPresented: $showShareSheet) {
+ ClipShareSheet(vCardData: snapshot.vCardData) {
+ showShareSheet = false
+ onSave()
}
- .padding(.horizontal, ClipDesign.Spacing.xLarge)
-
- Spacer(minLength: ClipDesign.Spacing.large)
}
}
diff --git a/BusinessCardClip/Views/Components/ClipPrimaryButton.swift b/BusinessCardClip/Views/Components/ClipPrimaryButton.swift
index a7df8e2..9e6349d 100644
--- a/BusinessCardClip/Views/Components/ClipPrimaryButton.swift
+++ b/BusinessCardClip/Views/Components/ClipPrimaryButton.swift
@@ -18,6 +18,7 @@ struct ClipPrimaryButton: View {
HStack(spacing: ClipDesign.Spacing.small) {
if let systemImage {
Image(systemName: systemImage)
+ .foregroundStyle(Color.Clip.background)
}
Text(title)
.styled(.headingEmphasis, emphasis: .custom(Color.Clip.background))
diff --git a/BusinessCardClip/Views/Components/ClipShareSheet.swift b/BusinessCardClip/Views/Components/ClipShareSheet.swift
new file mode 100644
index 0000000..a3ca048
--- /dev/null
+++ b/BusinessCardClip/Views/Components/ClipShareSheet.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+import UIKit
+
+/// Presents a share sheet with a vCard file so the user can add to Contacts.
+/// App Clips cannot use CNContactStore; the share sheet's "Add to Contacts" option works instead.
+struct ClipShareSheet: UIViewControllerRepresentable {
+ let vCardData: String
+ let onDismiss: (() -> Void)?
+
+ init(vCardData: String, onDismiss: (() -> Void)? = nil) {
+ self.vCardData = vCardData
+ self.onDismiss = onDismiss
+ }
+
+ func makeUIViewController(context: Context) -> UIActivityViewController {
+ let tempURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("contact-\(UUID().uuidString).vcf")
+ let data = Data(vCardData.utf8)
+ try? data.write(to: tempURL)
+
+ let controller = UIActivityViewController(
+ activityItems: [tempURL],
+ applicationActivities: nil
+ )
+ controller.completionWithItemsHandler = { _, _, _, _ in
+ try? FileManager.default.removeItem(at: tempURL)
+ onDismiss?()
+ }
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
+}