From fa3cb3e0170b669d35978f0a281efce5a9db1deb Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 18 Feb 2026 12:33:35 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard.code-workspace | 11 +++++ BusinessCard.xcodeproj/project.pbxproj | 31 +++++++++---- .../xcschemes/BusinessCardClip.xcscheme | 9 +++- .../xcschemes/xcschememanagement.plist | 4 +- BusinessCard/Resources/Localizable.xcstrings | 4 ++ .../ClipAccent.colorset/Contents.json | 46 +++++++++---------- BusinessCardClip/BusinessCardClipApp.swift | 27 +++++++++-- BusinessCardClip/State/ClipCardStore.swift | 11 ++++- BusinessCardClip/Views/ClipRootView.swift | 2 +- .../Views/Components/ClipCardPreview.swift | 35 ++++++++------ .../Views/Components/ClipPrimaryButton.swift | 1 + .../Views/Components/ClipShareSheet.swift | 33 +++++++++++++ 12 files changed, 160 insertions(+), 54 deletions(-) create mode 100644 BusinessCard.code-workspace create mode 100644 BusinessCardClip/Views/Components/ClipShareSheet.swift 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) {} +}