Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-18 12:33:35 -06:00
parent 2b679b0167
commit fa3cb3e017
12 changed files with 160 additions and 54 deletions

View File

@ -0,0 +1,11 @@
{
"folders": [
{
"path": "."
},
{
"path": "../_Packages/Bedrock"
}
],
"settings": {}
}

View File

@ -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 */ = {

View File

@ -53,7 +53,7 @@
<CommandLineArguments>
<CommandLineArgument
argument = "--clip-debug=preview"
isEnabled = "YES">
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--clip-debug=loading"
@ -68,6 +68,13 @@
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCAppClipURL"
value = "https://topdoglabs.com/bc?id=FE866C3C-EEBC-4A66-B176-11D0F01E28DF"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@ -7,7 +7,7 @@
<key>BusinessCard.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</integer>
</dict>
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
<dict>
@ -17,7 +17,7 @@
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>2</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -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" : {
},

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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))

View File

@ -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) {}
}