Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
eb01376b69
commit
236871f5bc
@ -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;
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
21
BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/Contents.json
vendored
Normal file
21
BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/DebugAvatar.jpg
vendored
Normal file
BIN
BusinessCardClip/Assets.xcassets/DebugAvatar.imageset/DebugAvatar.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
183
BusinessCardClip/Views/ClipDebugHarnessView.swift
Normal file
183
BusinessCardClip/Views/ClipDebugHarnessView.swift
Normal file
File diff suppressed because one or more lines are too long
@ -27,7 +27,7 @@ struct ClipRootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
.task(id: recordName) {
|
||||
await store.load(recordName: recordName)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
BusinessCardClip/Views/Components/ClipPrimaryButton.swift
Normal file
31
BusinessCardClip/Views/Components/ClipPrimaryButton.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user