diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj index 895dca1..6aed681 100644 --- a/BusinessCard.xcodeproj/project.pbxproj +++ b/BusinessCard.xcodeproj/project.pbxproj @@ -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; diff --git a/BusinessCard.xcodeproj/xcshareddata/xcschemes/BusinessCardClip.xcscheme b/BusinessCard.xcodeproj/xcshareddata/xcschemes/BusinessCardClip.xcscheme new file mode 100644 index 0000000..bc3a632 --- /dev/null +++ b/BusinessCard.xcodeproj/xcshareddata/xcschemes/BusinessCardClip.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index 2643284..cfbae5b 100644 --- a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,17 +7,25 @@ BusinessCard.xcscheme_^#shared#^_ orderHint - 2 + 1 BusinessCardClip.xcscheme_^#shared#^_ orderHint - 1 + 3 BusinessCardWatch Watch App.xcscheme_^#shared#^_ orderHint - 3 + 2 + + + SuppressBuildableAutocreation + + EACLIP0012F200000000004 + + primary + diff --git a/BusinessCardClip/Assets.xcassets/ClipAccent.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/ClipAccent.colorset/Contents.json new file mode 100644 index 0000000..c2d07cf --- /dev/null +++ b/BusinessCardClip/Assets.xcassets/ClipAccent.colorset/Contents.json @@ -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 + } +} diff --git a/BusinessCardClip/Assets.xcassets/ClipBackground.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/ClipBackground.colorset/Contents.json new file mode 100644 index 0000000..84488e5 --- /dev/null +++ b/BusinessCardClip/Assets.xcassets/ClipBackground.colorset/Contents.json @@ -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 + } +} diff --git a/BusinessCardClip/Assets.xcassets/ClipError.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/ClipError.colorset/Contents.json new file mode 100644 index 0000000..dbf4fcc --- /dev/null +++ b/BusinessCardClip/Assets.xcassets/ClipError.colorset/Contents.json @@ -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 + } +} diff --git a/BusinessCardClip/Assets.xcassets/ClipSuccess.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/ClipSuccess.colorset/Contents.json new file mode 100644 index 0000000..625e07e --- /dev/null +++ b/BusinessCardClip/Assets.xcassets/ClipSuccess.colorset/Contents.json @@ -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 + } +} diff --git a/BusinessCardClip/Assets.xcassets/ClipSurface.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/ClipSurface.colorset/Contents.json new file mode 100644 index 0000000..ce79407 --- /dev/null +++ b/BusinessCardClip/Assets.xcassets/ClipSurface.colorset/Contents.json @@ -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 + } +} diff --git a/BusinessCardClip/Assets.xcassets/ClipTextPrimary.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/ClipTextPrimary.colorset/Contents.json new file mode 100644 index 0000000..a0030ea --- /dev/null +++ b/BusinessCardClip/Assets.xcassets/ClipTextPrimary.colorset/Contents.json @@ -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 + } +} diff --git a/BusinessCardClip/Assets.xcassets/ClipTextSecondary.colorset/Contents.json b/BusinessCardClip/Assets.xcassets/ClipTextSecondary.colorset/Contents.json new file mode 100644 index 0000000..218d339 --- /dev/null +++ b/BusinessCardClip/Assets.xcassets/ClipTextSecondary.colorset/Contents.json @@ -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 + } +} diff --git a/BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/Contents.json b/BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/Contents.json new file mode 100644 index 0000000..687f101 --- /dev/null +++ b/BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/Contents.json @@ -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 + } +} diff --git a/BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/DebugAvatar.jpg b/BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/DebugAvatar.jpg new file mode 100644 index 0000000..3aad8c5 Binary files /dev/null and b/BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/DebugAvatar.jpg differ diff --git a/BusinessCardClip/BusinessCardClipApp.swift b/BusinessCardClip/BusinessCardClipApp.swift index 60566e9..13993f7 100644 --- a/BusinessCardClip/BusinessCardClipApp.swift +++ b/BusinessCardClip/BusinessCardClipApp.swift @@ -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 } diff --git a/BusinessCardClip/Design/ClipDesignConstants.swift b/BusinessCardClip/Design/ClipDesignConstants.swift index 202e9c4..b72fd4e 100644 --- a/BusinessCardClip/Design/ClipDesignConstants.swift +++ b/BusinessCardClip/Design/ClipDesignConstants.swift @@ -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") } } diff --git a/BusinessCardClip/Models/SharedCardSnapshot.swift b/BusinessCardClip/Models/SharedCardSnapshot.swift index ca8a159..a78e714 100644 --- a/BusinessCardClip/Models/SharedCardSnapshot.swift +++ b/BusinessCardClip/Models/SharedCardSnapshot.swift @@ -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[.. [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.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: ", ") + } } diff --git a/BusinessCardClip/State/ClipCardStore.swift b/BusinessCardClip/State/ClipCardStore.swift index b847d84..82febb2 100644 --- a/BusinessCardClip/State/ClipCardStore.swift +++ b/BusinessCardClip/State/ClipCardStore.swift @@ -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 + } } diff --git a/BusinessCardClip/Views/ClipDebugHarnessView.swift b/BusinessCardClip/Views/ClipDebugHarnessView.swift new file mode 100644 index 0000000..0dc300e --- /dev/null +++ b/BusinessCardClip/Views/ClipDebugHarnessView.swift @@ -0,0 +1,183 @@ +import SwiftUI +import Bedrock + +#if DEBUG +enum ClipDebugState: String, CaseIterable { + case loading + case preview + case success + case error +} + +enum ClipDebugFixtureVariant: String, CaseIterable { + case standard + case long +} + +/// Debug-only harness to preview all App Clip states without CloudKit. +struct ClipDebugHarnessView: View { + @State private var selectedState: ClipDebugState + @State private var selectedFixture: ClipDebugFixtureVariant = .standard + + init(initialState: ClipDebugState = .preview) { + _selectedState = State(initialValue: initialState) + } + + private var mockSnapshot: SharedCardSnapshot { + SharedCardSnapshot( + recordName: "debug-record", + vCardData: selectedFixture == .standard + ? ClipDebugFixtures.fullCoverageVCard + : ClipDebugFixtures.longCoverageVCard + ) + } + + var body: some View { + ZStack { + Color.Clip.background + .ignoresSafeArea() + + VStack(spacing: ClipDesign.Spacing.medium) { + Picker("State", selection: $selectedState) { + ForEach(ClipDebugState.allCases, id: \.rawValue) { state in + Text(state.rawValue.capitalized).tag(state) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, ClipDesign.Spacing.large) + .padding(.top, ClipDesign.Spacing.large) + + Picker("Fixture", selection: $selectedFixture) { + ForEach(ClipDebugFixtureVariant.allCases, id: \.rawValue) { variant in + Text(variant.rawValue.capitalized).tag(variant) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, ClipDesign.Spacing.large) + + if selectedState == .preview { + Text("Rows: \(mockSnapshot.contactInfoRows.count)") + .styled(.caption) + .foregroundStyle(Color.Clip.secondaryText) + } + + Group { + switch selectedState { + case .loading: + ClipLoadingView() + case .preview: + ScrollView(showsIndicators: false) { + ClipCardPreview(snapshot: mockSnapshot) { + selectedState = .success + } + } + case .success: + ClipSuccessView() + case .error: + ClipErrorView(message: String(localized: "This card has expired")) { + selectedState = .loading + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} + +private enum ClipDebugFixtures { + // Embedded debug photo bytes for deterministic preview rendering. + private static let base64PhotoJPEG = "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kmCV1K8hoOGV1K8hr46V1K8hvl2V769AzWuWS6+q3O+XF7CW7UCZRrMb//+WqcAYAACW/7/pGhaXU7+6K2OXsL+FO7CYD79RS1CYbL8fWm+Ywb70aSmZB77Td5CZLr7DhbOZML7Ck8iZML7CoiCZML7CsKuZL77Cv12ZQr7ZzkiZjr823Z6aRcAW7bacQMKC//+ZM9AXAACZpc/kGp6aFs+yLBialM94PJGbFs89TFubl88EW6OcEc7QaoSce86leRGcyc6Ih1ec4s5/lWOc4s5/o5Cc485/sfic485/wI6c2s51zzydFM7A3mGdsM+L7jifbdHM//+cD+BHAACcmuATGxWdI9/dLMadwN+gPXWeZN9gTWufB98hXN+fpN7ma+mgMt6zepygp96KiQig8d5zlzag4953pS6g3t53s3ig2d54wfeg09550J+gq95O3z6hId757tmik+D7//+fUfDMAACf7/CXG4egkvBgLYihSvAgPmaiDO/cTo6i0O+ZXjCjju9YbWakP+8dfESk2u7sitilUe7ImS6lB+7bptCk/u7btROk9O7bw4+k6e7b0jik3O7b4Qeke+5l736lmvAR//+jYP//AACkEP//HCOkx///Lmylmv//P5Gmev//T/unXP//X9WoO///b0SpDv//flqpzf//jSOqbv//m6ypwf//qN2pq///tyipnf//xbqpjv//1Hupff//42Kpav//8miof/8K//+XcBEaAACbMwHSAACbgAAAGFqbVgAALECbGwAAPjKa0QAAT2GaeQAAYCSaFgAAcJ6ZqQAAgOCZNgAAkPSYvwAAoOGYSAAAsK2X1AAAwGCXoQAA0E6X2QAA4MeYsAAA8j6boAAA//+XoBqgAACWDB2qFKuYNhksHq+YJhkYLySYEBj7P1qX9BjWT3GX0BiqX3OXpxh0b2SXeBg5f0aXRBf3jxqXDBewnt+W0RdorpaWlhcbvkSWlhbczjaW/Baz3ruX+xas8EGbChcZ//+X8SZ1AACWdCh9FO+VSyn/JGiWYSh5MTOWXChlQFWWVShMT7CWSygtXyuWPygIbraWMCfdfkuWHietjeaWCSd5nYKV8CdArR2V1ycGvLmV+CbdzKKWeybT3SKXlCb47qWatifD//+YbzQ8AACXEjWQFVKV/DaXJOeVDTd4M7WVbjcUQYeVcjcGUD+Vdzb1Xz+VezbgbmyVfjbHfbeVgTaqjRaVgTaKnISVgDZmq/2VfjZBu3+VtTYxy1SWTDZJ28OXdzad7TiapDfE//+ZF0LwAACX5UPKFcyW6kR5JYeWBkUaNHWVeEWAQtuVg0V3URaViEVyX7uVjkVrbp2VlUVifamVnEVXjNSVo0VJnBWVqkU5q2eVs0UnusmV80UvyoKWkkVm2teXxUXm7DGa8Udl//+Z6FIeAACY51KlFlaYDlMVJj2XP1OCNVKWpVPWQ9eWf1PsUgaWf1PsYHqWf1PsbzSWf1Psfh2Wf1PsjSWWf1PsnEOWf1Prq3KWg1PqurKWv1QHykmXW1Rb2nqYilUD66+bqFbQ//+a4mGRAACaFGHfFueZYmIiJwCYsWJlNj+YHmKgROmX1GLAUymX1GLAYWmX1GLAb/6X1GLAfsmX1GLAjbqX1GLAnMaX1GLAq+aX1mLBuxeYDGLmypeYn2NK2quZwmQQ67mcyWYf//+cBnEtAACba3FTF3ua4XF1J8maVHGYNzSZ13G6RgeZgXHUVGqZcXHZYouZcXHZcPKZcXHZf5qZcXHZjnCZcXHZnWaZcXHZrHSZc3Hbu5aZpXIBywWaMXJs2wSbSnND6/WeOHWA//+dVoDkAACc64DuGBKciYD3KI+cI4ECOCqbxYENRyibeoEYVbGbV4EfY+CbV4EfchKbV4EfgJKbV4Efj0ibV4EfniSbV4EfrR2bWIEhvC2bh4FIy4icCoG322ydF4Kb7Dyf54T7//+e05CsAACelZCjGJmeWpCaKVKeG5CRORyd4JCJSEWdrpCFVvadjZCEZUmdhpCEc2CdhpCEgbKdhpCEkEOdhpCEnwKdhpCEreKdh5CFvN6dsJCrzCCeKpEc2+afKZIH7I6h05R+//+gf6B/AACgaKBoGSqgUqBSKhCgO6A7OgmgJKAkSVygEaARWDWgBKAEZq6f/5//dNif/5//gvmf/5//kWGf/5//n/+f/5//rsSgAKAAvaigIqAizM+gkKCQ3HKhfaF97Oyj/KP8//+iWrBaAACiZ7A6GaGic7AbKsWiga/4Ouyij6/VSmyin6+2WW2irq+daAqiu6+Ndliiv6+JhGiiv6+JkqOiv6+JoRyiv6+Jr8KiwK+Jvo2i26+mzZSjOrAN3Q+kErD07VamYLNs//+kZcA8AACkksAVGh6kvb/vK3Kk7r/DO8mlIb+YS3GlU79uWpqlgr9KaV2lq78vd82lxL8ghfmlxr8flAqlxr8folqlxr8fsN+lxr8fv42l2L80znCmJ7+N3b+m5sBo7cuo+sLH//+mrdAzAACm9tAHGqenPs/cLCCnkM+qPKKn5c93THWoOc9FW8Wois8Yaq2o0c7zeUKpCc7Yh4+pH87PlaGpH87Po8epH87PsiipH87PwLipGM7Gz2SpU88O3oCp9s/S7kyrydIF//+pQuBWAACppOAoGxKqB9/4LM6qdt/CPX+q7N+JTX6rYd9RXPer098cbAesO97tesCslN7JiTKszt6yl2as2N6vpXKs2N6vs7Cs2N6vwiKs2N6v0L2su96K31StPd8v7tiux+Ei//+sO/DJAACssfCZG4KtLPBnLYetuPAtPmuuTO/vTpiu4e+xXj2vc+92bXev/O9AfFqwde8TivOw1e7xmUyxBe7hp2+xAu7itYixAe7iw96w/u7j0mOw++7j4Q6wqu5973Kx3fAb//+v+///AACwhf//HBqxFf//LmWxu///P4uya///T/SzH///X9Gz0f//b0G0ev//fli1Ff//jSS1mP//m6619f//qgG1zv//t+W1yP//xje1wP//1L21uP//4261rv//8kG01f73//+mpxP3AACp8gZrAACrHwAAFciq+QAAKuGqxQAAPUyqggAATsGqMQAAX7ep1AAAcFypbQAAgMSo/wAAkPuoiwAAoQqoFQAAsPanoAAAwManbgAA0M2nrAAA4VuolgAA8uGrvwAA//+m0RynAAClTB/DFRKn4RoDHYin0RnvLoCnuhnVPv+nnBmzT0eneRmHX22nThlWb3ynHRkdf3em5xjdj2GmrBiZnzqmbRhQrwWmLhgGvsOmLxfFzsSmmhed31OnrReT8N+q9RgB//+nGifkAAClpyoPFUukeCu7JPGl6CmLMQul4Sl3QFWl1yleT8+lyik/X2GluikZbwClpyjufqalkCi+jk6ldiiJnfalWShQrZ2lPCgUvUOlXCfozTOl5Sfb3benEif97zqqbijF//+nizVFAACmMja9FaGlFDfqJV+kDzj5NFSkrThHQdKkrzg3UJSksDgjX56ksTgMbtOksjfwfiaksDfRjYykrTeunQCkqDeHrH2kozdevASk2jdKy9uleTdd3EumuDes7buqHzjP//+oJEO1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fN/wFvAWi5LwCfAJmgbwAfABqETv/+//tlPv/+//xHvv/+//0tbv/+//4Vrv/+//7//xLvEu///zAP//AADzAv//G+jzBP//LjnzBf//P1TzB///T8XzCv//X6rzDf//byPzEP//fkPzFf//jRjzG///m6zzIf//qgjzJv//uDTzJv//xkbzJv//1IfzJv//4vbzJv//8Yny7v/E/////yYHAAD//x7UAAD//wgTAAD//wAAIwn//wAAO5P//wAAUJj//wAAZD///wAAdy7//wAAiar//wAAm9j//wAArcr//wAAv4z//wAA0SX//wAA4pv//wAA8/H//wAA///8LwAA/////yw+AAD//y+TGY///ygMHbT//yDlK+X//yDQQFT//yC1U2X//yCUZdX//yBtd+n//yBBicH//yAQm2///x/XrPz//x+Tvm3//x9Nz8f//x8E4Qv//x678jz//x5u///6rhwd/////zV2AAD//zghGab//zp+K0v//zNjMlv//zLJQ97//zKyVZP//zKUZxf//zJxeH///zJJidP//zIdmxb//zHnrE3//zGuvXf//zFyzpX//zEu36r//zDo8LT//zCf///5aC2P/////0GAAAD//0OMGcr//0VmK3n//0dMPET//0NxR1T//0NeV8r//0NHaG3//0MseSn//0MLifX//0Lmmsr//0K9q6T//0KNvIL//0JbzWD//0Ik3j7//0Hq7xr//0Gs//T4LT32/////085AAD//1DFGfv//1IyK7b//1OuPIr//1USTL///1NEWfr//1M0ac7//1Mgeen//1MHijP//1Lsmpz//1LMqxz//1Kpu6z//1KDzEf//1JZ3Oz//1Ir7Zb//1H7/kX3Jk24/////13lAAD//18PGjf//2AlLAD//2FLPN///2JlTR7//2NYXOP//2KfazX//2KSer///2KCipH//2Jvmpb//2JaqsD//2JCuwX//2Iny17//2IK28n//2Hq7ED//2HI/ML2cV0O/////20VAAD//230Gn3//27GLFD//2+oPT3//3CETYX//3FJXVT//3HebMP//3Gpe5///3Ghiwr//3GXmrb//3GLqpL//3F9upL//3Fuyq7//3Fc2uD//3FJ6yT//3Ez+3f2G2we/////3yFAAD//30pGrv//33GLKX//35xPZ7//38cTfL//3+4Xcn//4A4bT7//4B+fGn//4B6i4P//4B3muv//4B0qoj//4Bwuk7//4BqyjT//4Bj2jP//4Bb6kf//4BR+mz2L3r+/////4wGAAD//4x+Gvf//4zyLPj//41xPf7//43yTl3//45sXjz//47Vbbn//48dfOb//48ri+P//48rmxn//48rqon//48ruiX//48ryeL//48r2bn//48r6aT//48r+Z/2rYm9/////5t8AAD//5vRGzT//5wkLUT//5yAPlj//5zeTsD//506Xqj//52Nbiv//53NfVz//53qjEv//53qm0r//53qqon//53pufn//53pyY///53p2UH//53p6Qn//53p+OT3eJh7/////6rOAAD//6sIG3L//6tBLYz//6uBPqj//6vDTxr//6wFXwj//6xCbo///6x2fcT//6yYjLP//6ydm4H//6ydqor//6yducn//6ydyTH//6yd2Ln//6yd6Fv//6yd+BH4YKdY/////7nsAAD//7oQG6r//7o0LcT//7pdPuv//7qIT2P//7q0X1f//7rebuL//7sEfhj//7sijQj//7stm77//7stqov//7stuZP//7styMn//7st2CP//7st55n//7st9yb5Y7ZB/////8jGAAD//8jaG7z//8jtLfP//8kEPyD//8kcT5v//8k1X5L//8lObx///8llflb//8l7jUT//8mJm/f//8mKqoz//8mKuVj//8mKyFf//8mK137//8mK5sX//8mK9iX6gsUo/////9dRAAD//9dYG8r//9dfLhb//9dnP0b//9dvT8H//9d4X7j//9eCb0T//9eNfnn//9eXjWX//9ehnBP//9elqo3//9eluRn//9elx93//9el1s3//9el5d///9el9Q77u9QE/////+WFAAD//+WCG9T//+V+LiP//+V6P1b//+V2T9P//+VyX8f//+Vvb1D//+VtfoD//+VtjWf//+VvnA///+VxqoL//+VyuNX//+Vyx1r//+Vy1g7//+Vy5Or//+Vy8+T9DuLL//////NYAAD///NOG9r///NCLiv///M0P1n///MmT8////MXX77///MJb0H///L9fmz///LzjUv///Lsm+v///LoqlT///LnuI3///Lnxs////Ln1UX///Ln4+X///Ln8qf+e/F2////////AAD/////G93/////Lij/////P0f/////T7f/////X57/////bxn/////fjr/////jRD/////m6b/////qgT/////uDH/////xj3/////1HH/////4tL/////8Vn///////8AAGN1cnYAAAAAAAAAAgAA//9jdXJ2AAAAAAAAAAIAAP//Y3VydgAAAAAAAAACAAD//3NmMzIAAAAAAAEMPQAABdz///MoAAAHjwAA/ZL///ui///9ogAAA9sAAMB9ZGF0YQAAAAABAAAACQAAAP/AABEIAIAAgAMBIgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2wBDAAICAgICAgQCAgQGBAQEBggGBgYGCAoICAgICAoMCgoKCgoKDAwMDAwMDAwODg4ODg4QEBAQEBISEhISEhISEhL/2wBDAQMDAwUEBQgEBAgTDQsNExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExP/3QAEAAj/2gAMAwEAAhEDEQA/AP3Yopu7BAwea4fXbXW7jUXm07VbmyTy1jCJbJNGr8sXBI5JU4IOQMDHOcgFS71f4nwqTa6LZz5uCoH2rYRCMgOwKkbjgHAJwD3IIrH1XxD8aIdAW50fw1Y3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" + + // Matches the app's current vCard export patterns (BusinessCard.vCardLines). + static let fullCoverageVCard: String = [ + "BEGIN:VCARD", + "VERSION:3.0", + "N:Cost;Jon;A.;Mr.;Jr.", + "FN:Jon A. Cost", + "NICKNAME:Jon", + "PHOTO;ENCODING=b;TYPE=JPEG:\(base64PhotoJPEG)", + "ORG:Remax;Residential", + "TITLE:Sales Director", + "TEL;TYPE=CELL:+1-555-111-2222", + "TEL;TYPE=WORK:+1-555-333-4444", + "TEL;TYPE=HOME:+1-555-888-9999", + "EMAIL;TYPE=WORK:jon@remax.com", + "EMAIL;TYPE=PERSONAL:jon.cost@gmail.com", + "ADR;TYPE=WORK:;;123 Main St;Austin;TX;78701;USA", + "ADR;TYPE=HOME:;;98 Lake View Rd;Round Rock;TX;78681;USA", + "URL:https://www.remax.com", + "URL;TYPE=calendly:https://calendly.com/joncost/intro", + "URL;TYPE=portfolio:https://joncost.me", + "X-SOCIALPROFILE;TYPE=linkedin:https://linkedin.com/in/joncost", + "X-SOCIALPROFILE;TYPE=twitter:https://x.com/joncost", + "X-SOCIALPROFILE;TYPE=instagram:https://instagram.com/joncost", + "X-SOCIALPROFILE;TYPE=facebook:https://facebook.com/joncost", + "X-SOCIALPROFILE;TYPE=tiktok:https://tiktok.com/@joncost", + "X-SOCIALPROFILE;TYPE=github:https://github.com/joncost", + "X-SOCIALPROFILE;TYPE=threads:https://threads.net/@joncost", + "X-SOCIALPROFILE;TYPE=telegram:https://t.me/joncost", + "X-SOCIALPROFILE;TYPE=bluesky:https://bsky.app/profile/joncost.bsky.social", + "X-SOCIALPROFILE;TYPE=mastodon:https://mastodon.social/@joncost", + "X-SOCIALPROFILE;TYPE=youtube:https://youtube.com/@joncost", + "X-SOCIALPROFILE;TYPE=twitch:https://twitch.tv/joncost", + "X-SOCIALPROFILE;TYPE=reddit:https://reddit.com/user/joncost", + "X-SOCIALPROFILE;TYPE=snapchat:https://snapchat.com/add/joncost", + "X-SOCIALPROFILE;TYPE=pinterest:https://pinterest.com/joncost", + "X-SOCIALPROFILE;TYPE=discord:https://discord.gg/joncost", + "X-SOCIALPROFILE;TYPE=slack:https://remax.slack.com", + "X-SOCIALPROFILE;TYPE=whatsapp:https://wa.me/15551112222", + "X-SOCIALPROFILE;TYPE=signal:+15551112222", + "X-SOCIALPROFILE;TYPE=venmo:https://venmo.com/u/joncost", + "X-SOCIALPROFILE;TYPE=cashapp:https://cash.app/$joncost", + "X-SOCIALPROFILE;TYPE=paypal:https://paypal.me/joncost", + "NOTE:Top producer\\nPronouns: he/him\\nCredentials: Realtor, CRS", + "END:VCARD" + ].joined(separator: "\r\n") + + static let longCoverageVCard: String = [ + "BEGIN:VCARD", + "VERSION:3.0", + "N:Costington-Smythe;Jonathan Alexander;Maximilian Theodore;Mr.;III", + "FN:Jonathan Alexander Maximilian Theodore Costington-Smythe III", + "NICKNAME:Jonny Max The Third", + "PHOTO;ENCODING=b;TYPE=JPEG:\(base64PhotoJPEG)", + "ORG:Remax International Luxury & Commercial Advisory Group;Strategic Partnerships and High-Net-Worth Client Services Division", + "TITLE:Senior Regional Executive Director of Enterprise Business Development", + "TEL;TYPE=CELL:+1-555-111-2222 ext 987", + "TEL;TYPE=WORK:+1-555-333-4444 ext 12345", + "TEL;TYPE=HOME:+1-555-888-9999", + "EMAIL;TYPE=WORK:jonathan.alexander.costington-smythe.iii@global-remax-enterprise.com", + "EMAIL;TYPE=PERSONAL:jonny.max.the.third.personal.mailbox@examplemailprovider.com", + "ADR;TYPE=WORK:;;12345 Executive Plaza Boulevard Suite 1800;Austin;Texas;78759-1144;United States of America", + "ADR;TYPE=HOME:;;9876 Long Meadow Creek Residential Parkway Building C Apt 24B;Round Rock;Texas;78681;United States of America", + "URL:https://www.remax.com/enterprise/luxury-and-commercial/advisory-services", + "URL;TYPE=calendly:https://calendly.com/jonathan-costington-smythe-iii/enterprise-strategy-intro-call-45min", + "URL;TYPE=portfolio:https://jonathanalexandercostingtonsmythe.me/about-and-case-studies", + "X-SOCIALPROFILE;TYPE=linkedin:https://www.linkedin.com/in/jonathan-alexander-maximilian-theodore-costington-smythe-iii", + "X-SOCIALPROFILE;TYPE=twitter:https://x.com/JonathanCostingtonSmytheTheThird", + "X-SOCIALPROFILE;TYPE=instagram:https://instagram.com/jonathan.alexander.maximilian.theodore.the.third", + "X-SOCIALPROFILE;TYPE=facebook:https://facebook.com/jonathan.alexander.maximilian.theodore.costington.smythe.iii", + "X-SOCIALPROFILE;TYPE=tiktok:https://www.tiktok.com/@jonathan.alexander.maximilian.theodore.iii", + "X-SOCIALPROFILE;TYPE=github:https://github.com/jonathan-alexander-maximilian-theodore-costington-smythe", + "X-SOCIALPROFILE;TYPE=threads:https://www.threads.net/@jonathan.alexander.maximilian.theodore.iii", + "X-SOCIALPROFILE;TYPE=telegram:https://t.me/jonathan_alexander_maximilian_theodore_iii", + "X-SOCIALPROFILE;TYPE=bluesky:https://bsky.app/profile/jonathan-alexander-maximilian-theodore-iii.bsky.social", + "X-SOCIALPROFILE;TYPE=mastodon:https://mastodon.social/@jonathan_alexander_maximilian_theodore_iii", + "X-SOCIALPROFILE;TYPE=youtube:https://www.youtube.com/@JonathanAlexanderMaximilianTheodoreCostingtonSmytheIII", + "X-SOCIALPROFILE;TYPE=twitch:https://www.twitch.tv/jonathanalexandermaximiliantheodoreiii", + "X-SOCIALPROFILE;TYPE=reddit:https://www.reddit.com/user/jonathan_alexander_maximilian_iii", + "X-SOCIALPROFILE;TYPE=snapchat:https://www.snapchat.com/add/jonathan.alexander.maximilian.theodore.iii", + "X-SOCIALPROFILE;TYPE=pinterest:https://www.pinterest.com/jonathanalexandermaximiliantheodoreiii/", + "X-SOCIALPROFILE;TYPE=discord:https://discord.gg/jonathanalexandermaximiliantheodoreiiicommunity", + "X-SOCIALPROFILE;TYPE=slack:https://remax-enterprise-global-strategy.slack.com", + "X-SOCIALPROFILE;TYPE=whatsapp:https://wa.me/15551112222", + "X-SOCIALPROFILE;TYPE=signal:+15551112222", + "X-SOCIALPROFILE;TYPE=venmo:https://venmo.com/u/jonathanalexandermaximiliantheodoreiii", + "X-SOCIALPROFILE;TYPE=cashapp:https://cash.app/$jonathanalexmaxiii", + "X-SOCIALPROFILE;TYPE=paypal:https://paypal.me/jonathanalexandermaximiliantheodoreiii", + "NOTE:Top-producing advisor focused on long-cycle enterprise relationships and national account growth.\\nPronouns: he/him\\nCredentials: CRS, GRI, ABR, CIPS, SRES\\nSpeaking topics: negotiation strategy, market analytics, and leadership coaching.", + "END:VCARD" + ].joined(separator: "\r\n") +} +#endif diff --git a/BusinessCardClip/Views/ClipRootView.swift b/BusinessCardClip/Views/ClipRootView.swift index cb109b2..9e99704 100644 --- a/BusinessCardClip/Views/ClipRootView.swift +++ b/BusinessCardClip/Views/ClipRootView.swift @@ -27,7 +27,7 @@ struct ClipRootView: View { } } } - .task { + .task(id: recordName) { await store.load(recordName: recordName) } } diff --git a/BusinessCardClip/Views/Components/ClipCardPreview.swift b/BusinessCardClip/Views/Components/ClipCardPreview.swift index 1b2462c..97a2802 100644 --- a/BusinessCardClip/Views/Components/ClipCardPreview.swift +++ b/BusinessCardClip/Views/Components/ClipCardPreview.swift @@ -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) } } diff --git a/BusinessCardClip/Views/Components/ClipErrorView.swift b/BusinessCardClip/Views/Components/ClipErrorView.swift index ce84daa..e0bacfb 100644 --- a/BusinessCardClip/Views/Components/ClipErrorView.swift +++ b/BusinessCardClip/Views/Components/ClipErrorView.swift @@ -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) } diff --git a/BusinessCardClip/Views/Components/ClipLoadingView.swift b/BusinessCardClip/Views/Components/ClipLoadingView.swift index 49c2a24..4a9c958 100644 --- a/BusinessCardClip/Views/Components/ClipLoadingView.swift +++ b/BusinessCardClip/Views/Components/ClipLoadingView.swift @@ -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"))) } } diff --git a/BusinessCardClip/Views/Components/ClipPrimaryButton.swift b/BusinessCardClip/Views/Components/ClipPrimaryButton.swift new file mode 100644 index 0000000..a7df8e2 --- /dev/null +++ b/BusinessCardClip/Views/Components/ClipPrimaryButton.swift @@ -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) + } + } +} diff --git a/BusinessCardClip/Views/Components/ClipSuccessView.swift b/BusinessCardClip/Views/Components/ClipSuccessView.swift index 27c885c..a23e448 100644 --- a/BusinessCardClip/Views/Components/ClipSuccessView.swift +++ b/BusinessCardClip/Views/Components/ClipSuccessView.swift @@ -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) } }