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

This commit is contained in:
Matt Bruce 2026-02-11 18:20:59 -06:00
parent eb01376b69
commit 236871f5bc
23 changed files with 1220 additions and 111 deletions

View File

@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; };
EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69E9262F3D4B5700592220 /* 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, ); }; };
EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@ -165,6 +166,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -329,6 +331,7 @@
);
name = BusinessCardClip;
packageProductDependencies = (
EA69E9262F3D4B5700592220 /* Bedrock */,
);
productName = BusinessCardClip;
productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
@ -987,6 +990,11 @@
isa = XCSwiftPackageProductDependency;
productName = Bedrock;
};
EA69E9262F3D4B5700592220 /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
package = EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */;
productName = Bedrock;
};
EA837E662F107D6800077F87 /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
productName = Bedrock;

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EACLIP0012F200000000004"
BuildableName = "BusinessCardClip.app"
BlueprintName = "BusinessCardClip"
ReferencedContainer = "container:BusinessCard.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EACLIP0012F200000000004"
BuildableName = "BusinessCardClip.app"
BlueprintName = "BusinessCardClip"
ReferencedContainer = "container:BusinessCard.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "--clip-debug=preview"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "--clip-debug=loading"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--clip-debug=success"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--clip-debug=error"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EACLIP0012F200000000004"
BuildableName = "BusinessCardClip.app"
BlueprintName = "BusinessCardClip"
ReferencedContainer = "container:BusinessCard.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

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

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.860",
"green" : "0.470",
"red" : "0.220"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.950",
"green" : "0.650",
"red" : "0.350"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.975",
"green" : "0.965",
"red" : "0.960"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.150",
"green" : "0.130",
"red" : "0.120"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.220",
"green" : "0.220",
"red" : "0.820"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.350",
"green" : "0.350",
"red" : "0.950"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.360",
"green" : "0.640",
"red" : "0.190"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.450",
"green" : "0.750",
"red" : "0.300"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.995",
"green" : "0.988",
"red" : "0.985"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.220",
"green" : "0.190",
"red" : "0.180"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.170",
"green" : "0.150",
"red" : "0.140"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.975",
"green" : "0.965",
"red" : "0.960"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.440",
"green" : "0.390",
"red" : "0.360"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.750",
"green" : "0.720",
"red" : "0.700"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "DebugAvatar.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@ -3,12 +3,20 @@ import SwiftUI
@main
struct BusinessCardClipApp: App {
@State private var recordName: String?
@State private var launchErrorMessage: String?
@State private var debugState: ClipDebugState?
var body: some Scene {
WindowGroup {
Group {
if let recordName {
if let debugState {
ClipDebugHarnessView(initialState: debugState)
} else if let recordName {
ClipRootView(recordName: recordName)
} else if let launchErrorMessage {
ClipErrorView(message: launchErrorMessage) {
self.launchErrorMessage = nil
}
} else {
ClipLoadingView()
}
@ -19,6 +27,11 @@ struct BusinessCardClipApp: App {
.onOpenURL { url in
handleURL(url)
}
.task {
#if DEBUG
debugState = parseDebugStateArgument()
#endif
}
}
}
@ -28,10 +41,39 @@ struct BusinessCardClipApp: App {
}
private func handleURL(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let id = components.queryItems?.first(where: { $0.name == "id" })?.value else {
guard let id = extractRecordName(from: url) else {
launchErrorMessage = ClipError.invalidRecord.localizedDescription
return
}
launchErrorMessage = nil
recordName = id
}
private func extractRecordName(from url: URL) -> String? {
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let id = components.queryItems?
.first(where: { $0.name == ClipDesign.URL.recordQueryName })?
.value,
!id.isEmpty {
return id
}
// Fallback for path-based links (e.g. /clip/{recordName}).
let candidate = url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !candidate.isEmpty, candidate != "/" else { return nil }
return candidate
}
#if DEBUG
private func parseDebugStateArgument() -> ClipDebugState? {
let prefix = "--clip-debug="
guard let argument = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }) else {
return nil
}
let value = String(argument.dropFirst(prefix.count))
return ClipDebugState(rawValue: value)
}
#endif
}

View File

@ -1,7 +1,7 @@
import SwiftUI
/// Design constants for the App Clip.
/// Mirrors main app design but with minimal footprint for size constraints.
/// Mirrors the main app's centralized constant patterns.
enum ClipDesign {
// MARK: - Spacing
@ -28,8 +28,17 @@ enum ClipDesign {
enum Size {
static let avatar: CGFloat = 80
static let avatarLarge: CGFloat = 120
static let avatarLarge: CGFloat = 112
static let buttonHeight: CGFloat = 50
static let previewBannerHeight: CGFloat = 132
static let previewAvatarSize: CGFloat = 96
static let previewAvatarOverlap: CGFloat = 48
static let previewMaxWidth: CGFloat = 460
static let previewCardMinHeight: CGFloat = 320
static let avatarFallbackSymbolSize: CGFloat = 42
static let avatarStrokeWidth: CGFloat = 4
static let cardStrokeWidth: CGFloat = 1
static let contactRowIconSize: CGFloat = 14
}
// MARK: - Opacity
@ -38,6 +47,22 @@ enum ClipDesign {
static let subtle: Double = 0.3
static let medium: Double = 0.5
static let strong: Double = 0.7
static let faint: Double = 0.12
}
// MARK: - Shadow
enum Shadow {
static let radius: CGFloat = 14
static let y: CGFloat = 8
}
// MARK: - URLs
enum URL {
static let appStore = "https://apps.apple.com/app/id1234567890"
static let contactsScheme = "contacts://"
static let recordQueryName = "id"
}
}
@ -46,12 +71,12 @@ enum ClipDesign {
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)
static let background = Color("ClipBackground")
static let cardBackground = Color("ClipSurface")
static let text = Color("ClipTextPrimary")
static let secondaryText = Color("ClipTextSecondary")
static let accent = Color("ClipAccent")
static let success = Color("ClipSuccess")
static let error = Color("ClipError")
}
}

View File

@ -1,13 +1,48 @@
import Foundation
import Contacts
/// Represents a shared card fetched from CloudKit for display in the App Clip.
struct SharedCardSnapshot: Sendable {
struct ContactInfoRow: Identifiable, Sendable {
enum Kind: Sendable {
case phone
case email
case website
case address
case social
case note
}
let id = UUID()
let kind: Kind
let value: String
let label: String
var systemImage: String {
switch kind {
case .phone:
return "phone.fill"
case .email:
return "envelope.fill"
case .website:
return "globe"
case .address:
return "location.fill"
case .social:
return "link"
case .note:
return "note.text"
}
}
}
let recordName: String
let vCardData: String
let displayName: String
let role: String
let company: String
let photoData: Data?
let contactInfoRows: [ContactInfoRow]
init(
recordName: String,
@ -22,7 +57,7 @@ struct SharedCardSnapshot: Sendable {
// Parse display fields from vCard
let lines = vCardData.components(separatedBy: .newlines)
let parsedDisplayName = Self.parseField("FN:", from: lines) ?? "Contact"
let parsedDisplayName = Self.parseField("FN:", from: lines) ?? String(localized: "Contact")
let parsedRole = Self.parseField("TITLE:", from: lines) ?? ""
let parsedCompany = Self.parseField("ORG:", from: lines)?
.components(separatedBy: ";").first ?? ""
@ -33,6 +68,7 @@ struct SharedCardSnapshot: Sendable {
self.role = role ?? parsedRole
self.company = company ?? parsedCompany
self.photoData = photoData ?? parsedPhotoData
self.contactInfoRows = Self.parseContactInfoRows(from: lines, vCardData: vCardData)
}
private static func parseField(_ prefix: String, from lines: [String]) -> String? {
@ -42,12 +78,258 @@ struct SharedCardSnapshot: Sendable {
}
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 {
// Handles PHOTO on a single line. Folded/multiline photos are out of scope here.
guard let photoLine = lines.first(where: { $0.uppercased().hasPrefix("PHOTO") }),
let base64Start = photoLine.firstIndex(of: ":") else {
return nil
}
let base64String = String(photoLine[base64Start...])
let valueStart = photoLine.index(after: base64Start)
let base64String = String(photoLine[valueStart...]).trimmingCharacters(in: .whitespacesAndNewlines)
return Data(base64Encoded: base64String)
}
private static func parseContactInfoRows(from lines: [String], vCardData: String) -> [ContactInfoRow] {
var rows = parseContactRowsWithContactsFramework(vCardData: vCardData)
var existingKeys = Set(rows.map { dedupeKey(for: $0) })
let useManualFallback = rows.isEmpty
for line in lines {
guard let separatorIndex = line.firstIndex(of: ":") else { continue }
let metadata = String(line[..<separatorIndex])
let metadataUpper = metadata.uppercased()
let rawValue = String(line[line.index(after: separatorIndex)...])
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !rawValue.isEmpty else { continue }
if metadataUpper.hasPrefix("X-SOCIALPROFILE") {
let row = ContactInfoRow(
kind: .social,
value: rawValue,
label: normalizeSocialLabel(extractType(from: metadata) ?? "Social")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if useManualFallback {
// Fallback when Contacts framework parsing fails.
if metadataUpper.hasPrefix("TEL") {
let row = ContactInfoRow(
kind: .phone,
value: rawValue,
label: normalizeLabel(extractType(from: metadata) ?? "Phone")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if metadataUpper.hasPrefix("EMAIL") {
let row = ContactInfoRow(
kind: .email,
value: rawValue,
label: normalizeLabel(extractType(from: metadata) ?? "Email")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if metadataUpper.hasPrefix("ADR") {
let row = ContactInfoRow(
kind: .address,
value: formatAddress(rawValue),
label: normalizeLabel(extractType(from: metadata) ?? "Address")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if metadataUpper.hasPrefix("URL") {
let row = ContactInfoRow(
kind: .website,
value: rawValue,
label: normalizeLabel(extractType(from: metadata) ?? "Website")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if metadataUpper.hasPrefix("NOTE") {
let row = ContactInfoRow(
kind: .note,
value: rawValue.replacingOccurrences(of: "\\n", with: "\n"),
label: "Note"
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
}
} else if metadataUpper.hasPrefix("NOTE") {
let row = ContactInfoRow(
kind: .note,
value: rawValue.replacingOccurrences(of: "\\n", with: "\n"),
label: "Note"
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
}
}
return rows
}
private static func parseContactRowsWithContactsFramework(vCardData: String) -> [ContactInfoRow] {
guard let data = vCardData.data(using: .utf8),
let contact = try? CNContactVCardSerialization.contacts(with: data).first else {
return []
}
var rows: [ContactInfoRow] = []
for phone in contact.phoneNumbers {
let value = phone.value.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
rows.append(
ContactInfoRow(
kind: .phone,
value: value,
label: normalizeLabel(localizedLabel(phone.label))
)
)
}
for email in contact.emailAddresses {
let value = String(email.value).trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
rows.append(
ContactInfoRow(
kind: .email,
value: value,
label: normalizeLabel(localizedLabel(email.label, fallback: "Email"))
)
)
}
for address in contact.postalAddresses {
let value = CNPostalAddressFormatter.string(from: address.value, style: .mailingAddress)
.replacingOccurrences(of: "\n", with: ", ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
rows.append(
ContactInfoRow(
kind: .address,
value: value,
label: normalizeLabel(localizedLabel(address.label, fallback: "Address"))
)
)
}
for urlAddress in contact.urlAddresses {
let value = String(urlAddress.value).trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
rows.append(
ContactInfoRow(
kind: .website,
value: value,
label: normalizeLabel(localizedLabel(urlAddress.label, fallback: "Website"))
)
)
}
let noteValue = contact.note.trimmingCharacters(in: .whitespacesAndNewlines)
if !noteValue.isEmpty {
rows.append(
ContactInfoRow(
kind: .note,
value: noteValue,
label: "Note"
)
)
}
return rows
}
private static func localizedLabel(_ label: String?, fallback: String = "Work") -> String {
guard let label else { return fallback }
return CNLabeledValue<NSString>.localizedString(forLabel: label)
}
private static func dedupeKey(for row: ContactInfoRow) -> String {
"\(row.kind)-\(row.value.lowercased())"
}
private static func extractType(from metadata: String) -> String? {
let parameters = metadata.components(separatedBy: ";")
for parameter in parameters {
let parts = parameter.components(separatedBy: "=")
guard parts.count == 2, parts[0].uppercased() == "TYPE" else { continue }
let rawType = parts[1].components(separatedBy: ",").first ?? parts[1]
return rawType.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func normalizeLabel(_ raw: String) -> String {
switch raw.uppercased() {
case "CELL":
return "Cell"
case "WORK":
return "Work"
case "HOME":
return "Home"
case "PERSONAL":
return "Personal"
default:
return raw.capitalized
}
}
private static func normalizeSocialLabel(_ raw: String) -> String {
switch raw.lowercased() {
case "linkedin":
return "LinkedIn"
case "twitter":
return "X"
case "tiktok":
return "TikTok"
case "youtube":
return "YouTube"
case "cashapp":
return "Cash App"
default:
return raw.capitalized
}
}
private static func formatAddress(_ adrValue: String) -> String {
let parts = adrValue
.split(separator: ";", omittingEmptySubsequences: false)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
guard parts.count >= 7 else { return adrValue }
let street = parts[2]
let city = parts[3]
let state = parts[4]
let postalCode = parts[5]
let country = parts[6]
let locality = [city, state, postalCode]
.filter { !$0.isEmpty }
.joined(separator: " ")
return [street, locality, country]
.filter { !$0.isEmpty }
.joined(separator: ", ")
}
}

View File

@ -39,7 +39,7 @@ final class ClipCardStore {
let snapshot = try await cloudKit.fetchSharedCard(recordName: recordName)
state = .loaded(snapshot)
} catch {
state = .error(error.localizedDescription)
state = .error(userMessage(for: error, fallback: ClipError.fetchFailed))
}
}
@ -52,7 +52,14 @@ final class ClipCardStore {
try? await cloudKit.deleteSharedCard(recordName: snapshot.recordName)
state = .saved
} catch {
state = .error(error.localizedDescription)
state = .error(userMessage(for: error, fallback: ClipError.contactSaveFailed))
}
}
private func userMessage(for error: Error, fallback: ClipError) -> String {
if let clipError = error as? ClipError {
return clipError.localizedDescription
}
return fallback.localizedDescription
}
}

File diff suppressed because one or more lines are too long

View File

@ -27,7 +27,7 @@ struct ClipRootView: View {
}
}
}
.task {
.task(id: recordName) {
await store.load(recordName: recordName)
}
}

View File

@ -1,95 +1,244 @@
import SwiftUI
import Bedrock
/// Displays a preview of the shared card with option to save to Contacts.
struct ClipCardPreview: View {
private struct ContactSection {
let title: String
let rows: [SharedCardSnapshot.ContactInfoRow]
}
let snapshot: SharedCardSnapshot
let onSave: () -> Void
var body: some View {
VStack(spacing: ClipDesign.Spacing.xLarge) {
Spacer()
Spacer(minLength: ClipDesign.Spacing.medium)
// 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)
previewCard
.frame(maxWidth: ClipDesign.Size.previewMaxWidth)
.padding(.horizontal, ClipDesign.Spacing.large)
VStack(spacing: ClipDesign.Spacing.medium) {
// Save button
ClipPrimaryButton(
title: String(localized: "Save to Contacts"),
systemImage: "person.crop.circle.badge.plus",
action: onSave
)
.accessibilityLabel(Text("Save \(snapshot.displayName) to contacts"))
// Get full app prompt
Button {
openAppStore()
} label: {
Text(String(localized: "Get the full app"))
.styled(.subheading)
.foregroundStyle(Color.Clip.accent)
}
}
.padding(.horizontal, ClipDesign.Spacing.xLarge)
// Name
Spacer(minLength: ClipDesign.Spacing.large)
}
}
private var previewCard: some View {
VStack(spacing: 0) {
previewHeader
VStack(alignment: .leading, spacing: ClipDesign.Spacing.medium) {
Text(snapshot.displayName)
.font(.title)
.bold()
.styled(.title2)
.foregroundStyle(Color.Clip.text)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .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)
.styled(.headingEmphasis)
.foregroundStyle(Color.Clip.text)
.multilineTextAlignment(.center)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .center)
}
if !snapshot.company.isEmpty {
Text(snapshot.company)
.font(.subheadline)
.styled(.subheading)
.foregroundStyle(Color.Clip.secondaryText)
.multilineTextAlignment(.center)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
.padding(ClipDesign.Spacing.xLarge)
.frame(maxWidth: .infinity)
.background(Color.Clip.cardBackground)
.clipShape(.rect(cornerRadius: ClipDesign.CornerRadius.xLarge))
.padding(.horizontal, ClipDesign.Spacing.large)
Spacer()
Divider()
.overlay(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint))
// Save button
Button(action: onSave) {
HStack(spacing: ClipDesign.Spacing.small) {
Image(systemName: "person.crop.circle.badge.plus")
Text("Save to Contacts")
if !contactSections.isEmpty {
contactSectionsView
Divider()
.overlay(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint))
}
.font(.headline)
.foregroundStyle(Color.Clip.background)
.frame(maxWidth: .infinity)
.frame(height: ClipDesign.Size.buttonHeight)
.background(Color.Clip.accent)
.clipShape(.capsule)
Text(String(localized: "Shared business card"))
.styled(.caption)
.foregroundStyle(Color.Clip.secondaryText)
.frame(maxWidth: .infinity, alignment: .center)
}
.padding(.horizontal, ClipDesign.Spacing.xLarge)
.accessibilityLabel(Text("Save \(snapshot.displayName) to contacts"))
.padding(.bottom, ClipDesign.Spacing.xLarge)
}
.frame(minHeight: ClipDesign.Size.previewCardMinHeight)
.background(Color.Clip.cardBackground)
.clipShape(.rect(cornerRadius: ClipDesign.CornerRadius.xLarge))
.shadow(
color: Color.Clip.text.opacity(ClipDesign.Opacity.faint),
radius: ClipDesign.Shadow.radius,
x: 0,
y: ClipDesign.Shadow.y
)
.overlay(
RoundedRectangle(cornerRadius: ClipDesign.CornerRadius.xLarge)
.stroke(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.subtle), lineWidth: ClipDesign.Size.cardStrokeWidth)
)
}
// Get full app prompt
Button {
openAppStore()
} label: {
Text("Get the full app")
.font(.subheadline)
.foregroundStyle(Color.Clip.accent)
private var contactSectionsView: some View {
VStack(alignment: .leading, spacing: ClipDesign.Spacing.medium) {
ForEach(contactSections, id: \.title) { section in
VStack(alignment: .leading, spacing: ClipDesign.Spacing.xSmall) {
Text(section.title.uppercased())
.styled(.caption)
.foregroundStyle(Color.Clip.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
ForEach(Array(section.rows.enumerated()), id: \.element.id) { index, row in
contactRow(row)
.padding(.horizontal, ClipDesign.Spacing.medium)
.padding(.vertical, ClipDesign.Spacing.small)
if index < section.rows.count - 1 {
Divider()
.overlay(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint))
.padding(.leading, ClipDesign.Spacing.medium)
}
}
}
.background(Color.Clip.background.opacity(ClipDesign.Opacity.faint))
.clipShape(.rect(cornerRadius: ClipDesign.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: ClipDesign.CornerRadius.medium)
.stroke(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint), lineWidth: ClipDesign.Size.cardStrokeWidth)
)
}
}
.padding(.bottom, ClipDesign.Spacing.large)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func contactRow(_ row: SharedCardSnapshot.ContactInfoRow) -> some View {
HStack(alignment: .top, spacing: ClipDesign.Spacing.small) {
Image(systemName: row.systemImage)
.font(.system(size: ClipDesign.Size.contactRowIconSize, weight: .semibold))
.foregroundStyle(Color.Clip.secondaryText)
.frame(width: ClipDesign.Size.contactRowIconSize + ClipDesign.Spacing.small)
VStack(alignment: .leading, spacing: ClipDesign.Spacing.xSmall) {
Text(row.value)
.styled(.subheading)
.foregroundStyle(Color.Clip.text)
.lineLimit(row.kind == .note ? nil : 2)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
Text(row.label)
.styled(.caption)
.foregroundStyle(Color.Clip.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var contactSections: [ContactSection] {
let groups = Dictionary(grouping: snapshot.contactInfoRows, by: \.kind)
let order: [(SharedCardSnapshot.ContactInfoRow.Kind, String)] = [
(.phone, String(localized: "Phone")),
(.email, String(localized: "Email")),
(.address, String(localized: "Address")),
(.website, String(localized: "Links")),
(.social, String(localized: "Social Profiles")),
(.note, String(localized: "Notes"))
]
return order.compactMap { kind, title in
guard let rows = groups[kind], !rows.isEmpty else { return nil }
return ContactSection(title: title, rows: rows)
}
}
private var previewHeader: some View {
ZStack(alignment: .bottom) {
LinearGradient(
colors: [
Color.Clip.accent.opacity(ClipDesign.Opacity.strong),
Color.Clip.accent.opacity(ClipDesign.Opacity.subtle)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(height: ClipDesign.Size.previewBannerHeight)
avatarView
.offset(y: ClipDesign.Size.previewAvatarOverlap)
}
.padding(.bottom, ClipDesign.Size.previewAvatarOverlap)
}
private var avatarView: some View {
ZStack {
Circle()
.fill(Color.Clip.background.opacity(ClipDesign.Opacity.medium))
if let photoData = snapshot.photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Image(systemName: "person.fill")
.resizable()
.scaledToFit()
.frame(width: ClipDesign.Size.avatarFallbackSymbolSize, height: ClipDesign.Size.avatarFallbackSymbolSize)
.foregroundStyle(Color.Clip.secondaryText)
}
}
.frame(width: ClipDesign.Size.previewAvatarSize, height: ClipDesign.Size.previewAvatarSize)
.clipShape(.circle)
.overlay(
Circle()
.stroke(Color.Clip.cardBackground, lineWidth: ClipDesign.Size.avatarStrokeWidth)
)
.shadow(
color: Color.Clip.text.opacity(ClipDesign.Opacity.faint),
radius: ClipDesign.Shadow.radius,
x: 0,
y: ClipDesign.Shadow.y
)
}
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") {
if let url = URL(string: ClipDesign.URL.appStore) {
UIApplication.shared.open(url)
}
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
/// Error state shown when card fetch or save fails.
struct ClipErrorView: View {
@ -14,26 +15,21 @@ struct ClipErrorView: View {
.foregroundStyle(Color.Clip.error)
VStack(spacing: ClipDesign.Spacing.small) {
Text("Something went wrong")
.font(.title2)
.bold()
Text(String(localized: "Something went wrong"))
.styled(.title2)
.foregroundStyle(Color.Clip.text)
Text(message)
.font(.subheadline)
.styled(.subheading)
.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)
}
ClipPrimaryButton(
title: String(localized: "Try Again"),
systemImage: "arrow.clockwise",
action: onRetry
)
.padding(.horizontal, ClipDesign.Spacing.xLarge)
.padding(.top, ClipDesign.Spacing.large)
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
/// Loading state view shown while fetching the card from CloudKit.
struct ClipLoadingView: View {
@ -8,12 +9,12 @@ struct ClipLoadingView: View {
.scaleEffect(1.5)
.tint(Color.Clip.accent)
Text("Loading card...")
.font(.headline)
Text(String(localized: "Loading card..."))
.styled(.heading)
.foregroundStyle(Color.Clip.text)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(Text("Loading business card"))
.accessibilityLabel(Text(String(localized: "Loading business card")))
}
}

View File

@ -0,0 +1,31 @@
import SwiftUI
import Bedrock
/// Primary CTA button used across App Clip views.
struct ClipPrimaryButton: View {
let title: String
let systemImage: String?
let action: () -> Void
init(title: String, systemImage: String? = nil, action: @escaping () -> Void) {
self.title = title
self.systemImage = systemImage
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: ClipDesign.Spacing.small) {
if let systemImage {
Image(systemName: systemImage)
}
Text(title)
.styled(.headingEmphasis, emphasis: .custom(Color.Clip.background))
}
.frame(maxWidth: .infinity)
.frame(height: ClipDesign.Size.buttonHeight)
.background(Color.Clip.accent)
.clipShape(.capsule)
}
}
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
/// Success state shown after the contact has been saved.
struct ClipSuccessView: View {
@ -17,29 +18,22 @@ struct ClipSuccessView: View {
.animation(.spring(response: 0.5, dampingFraction: 0.6), value: showCheckmark)
VStack(spacing: ClipDesign.Spacing.small) {
Text("Contact Saved!")
.font(.title2)
.bold()
Text(String(localized: "Contact Saved!"))
.styled(.title2)
.foregroundStyle(Color.Clip.text)
Text("You can find this contact in your Contacts app.")
.font(.subheadline)
Text(String(localized: "You can find this contact in your Contacts app."))
.styled(.subheading)
.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)
}
ClipPrimaryButton(
title: String(localized: "Open Contacts"),
systemImage: "person.crop.circle",
action: openContacts
)
.padding(.horizontal, ClipDesign.Spacing.xLarge)
.padding(.top, ClipDesign.Spacing.large)
}
@ -48,11 +42,11 @@ struct ClipSuccessView: View {
showCheckmark = true
}
.accessibilityElement(children: .combine)
.accessibilityLabel(Text("Contact saved successfully"))
.accessibilityLabel(Text(String(localized: "Contact saved successfully")))
}
private func openContacts() {
if let url = URL(string: "contacts://") {
if let url = URL(string: ClipDesign.URL.contactsScheme) {
UIApplication.shared.open(url)
}
}