Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
eb01376b69
commit
236871f5bc
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; };
|
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 */; };
|
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, ); }; };
|
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, ); }; };
|
EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
@ -165,6 +166,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -329,6 +331,7 @@
|
|||||||
);
|
);
|
||||||
name = BusinessCardClip;
|
name = BusinessCardClip;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
EA69E9262F3D4B5700592220 /* Bedrock */,
|
||||||
);
|
);
|
||||||
productName = BusinessCardClip;
|
productName = BusinessCardClip;
|
||||||
productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
|
productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
|
||||||
@ -987,6 +990,11 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Bedrock;
|
productName = Bedrock;
|
||||||
};
|
};
|
||||||
|
EA69E9262F3D4B5700592220 /* Bedrock */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */;
|
||||||
|
productName = Bedrock;
|
||||||
|
};
|
||||||
EA837E662F107D6800077F87 /* Bedrock */ = {
|
EA837E662F107D6800077F87 /* Bedrock */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Bedrock;
|
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>
|
<key>BusinessCard.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>2</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
|
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<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>
|
</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
|
@main
|
||||||
struct BusinessCardClipApp: App {
|
struct BusinessCardClipApp: App {
|
||||||
@State private var recordName: String?
|
@State private var recordName: String?
|
||||||
|
@State private var launchErrorMessage: String?
|
||||||
|
@State private var debugState: ClipDebugState?
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
Group {
|
||||||
if let recordName {
|
if let debugState {
|
||||||
|
ClipDebugHarnessView(initialState: debugState)
|
||||||
|
} else if let recordName {
|
||||||
ClipRootView(recordName: recordName)
|
ClipRootView(recordName: recordName)
|
||||||
|
} else if let launchErrorMessage {
|
||||||
|
ClipErrorView(message: launchErrorMessage) {
|
||||||
|
self.launchErrorMessage = nil
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ClipLoadingView()
|
ClipLoadingView()
|
||||||
}
|
}
|
||||||
@ -19,6 +27,11 @@ struct BusinessCardClipApp: App {
|
|||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleURL(url)
|
handleURL(url)
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
#if DEBUG
|
||||||
|
debugState = parseDebugStateArgument()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,10 +41,39 @@ struct BusinessCardClipApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleURL(_ url: URL) {
|
private func handleURL(_ url: URL) {
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
guard let id = extractRecordName(from: url) else {
|
||||||
let id = components.queryItems?.first(where: { $0.name == "id" })?.value else {
|
launchErrorMessage = ClipError.invalidRecord.localizedDescription
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
launchErrorMessage = nil
|
||||||
recordName = id
|
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
|
import SwiftUI
|
||||||
|
|
||||||
/// Design constants for the App Clip.
|
/// 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 {
|
enum ClipDesign {
|
||||||
|
|
||||||
// MARK: - Spacing
|
// MARK: - Spacing
|
||||||
@ -28,8 +28,17 @@ enum ClipDesign {
|
|||||||
|
|
||||||
enum Size {
|
enum Size {
|
||||||
static let avatar: CGFloat = 80
|
static let avatar: CGFloat = 80
|
||||||
static let avatarLarge: CGFloat = 120
|
static let avatarLarge: CGFloat = 112
|
||||||
static let buttonHeight: CGFloat = 50
|
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
|
// MARK: - Opacity
|
||||||
@ -38,6 +47,22 @@ enum ClipDesign {
|
|||||||
static let subtle: Double = 0.3
|
static let subtle: Double = 0.3
|
||||||
static let medium: Double = 0.5
|
static let medium: Double = 0.5
|
||||||
static let strong: Double = 0.7
|
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 {
|
extension Color {
|
||||||
|
|
||||||
enum Clip {
|
enum Clip {
|
||||||
static let background = Color(red: 0.12, green: 0.13, blue: 0.15)
|
static let background = Color("ClipBackground")
|
||||||
static let cardBackground = Color(red: 0.18, green: 0.19, blue: 0.22)
|
static let cardBackground = Color("ClipSurface")
|
||||||
static let text = Color(red: 0.96, green: 0.96, blue: 0.97)
|
static let text = Color("ClipTextPrimary")
|
||||||
static let secondaryText = Color(red: 0.70, green: 0.72, blue: 0.75)
|
static let secondaryText = Color("ClipTextSecondary")
|
||||||
static let accent = Color(red: 0.35, green: 0.65, blue: 0.95)
|
static let accent = Color("ClipAccent")
|
||||||
static let success = Color(red: 0.30, green: 0.75, blue: 0.45)
|
static let success = Color("ClipSuccess")
|
||||||
static let error = Color(red: 0.95, green: 0.35, blue: 0.35)
|
static let error = Color("ClipError")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,48 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Contacts
|
||||||
|
|
||||||
/// Represents a shared card fetched from CloudKit for display in the App Clip.
|
/// Represents a shared card fetched from CloudKit for display in the App Clip.
|
||||||
struct SharedCardSnapshot: Sendable {
|
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 recordName: String
|
||||||
let vCardData: String
|
let vCardData: String
|
||||||
let displayName: String
|
let displayName: String
|
||||||
let role: String
|
let role: String
|
||||||
let company: String
|
let company: String
|
||||||
let photoData: Data?
|
let photoData: Data?
|
||||||
|
let contactInfoRows: [ContactInfoRow]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
recordName: String,
|
recordName: String,
|
||||||
@ -22,7 +57,7 @@ struct SharedCardSnapshot: Sendable {
|
|||||||
|
|
||||||
// Parse display fields from vCard
|
// Parse display fields from vCard
|
||||||
let lines = vCardData.components(separatedBy: .newlines)
|
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 parsedRole = Self.parseField("TITLE:", from: lines) ?? ""
|
||||||
let parsedCompany = Self.parseField("ORG:", from: lines)?
|
let parsedCompany = Self.parseField("ORG:", from: lines)?
|
||||||
.components(separatedBy: ";").first ?? ""
|
.components(separatedBy: ";").first ?? ""
|
||||||
@ -33,6 +68,7 @@ struct SharedCardSnapshot: Sendable {
|
|||||||
self.role = role ?? parsedRole
|
self.role = role ?? parsedRole
|
||||||
self.company = company ?? parsedCompany
|
self.company = company ?? parsedCompany
|
||||||
self.photoData = photoData ?? parsedPhotoData
|
self.photoData = photoData ?? parsedPhotoData
|
||||||
|
self.contactInfoRows = Self.parseContactInfoRows(from: lines, vCardData: vCardData)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func parseField(_ prefix: String, from lines: [String]) -> String? {
|
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? {
|
private static func parsePhoto(from lines: [String]) -> Data? {
|
||||||
// Find line that starts with PHOTO; and contains base64 data
|
// Handles PHOTO on a single line. Folded/multiline photos are out of scope here.
|
||||||
guard let photoLine = lines.first(where: { $0.hasPrefix("PHOTO;") }),
|
guard let photoLine = lines.first(where: { $0.uppercased().hasPrefix("PHOTO") }),
|
||||||
let base64Start = photoLine.range(of: ":")?.upperBound else {
|
let base64Start = photoLine.firstIndex(of: ":") else {
|
||||||
return nil
|
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)
|
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)
|
let snapshot = try await cloudKit.fetchSharedCard(recordName: recordName)
|
||||||
state = .loaded(snapshot)
|
state = .loaded(snapshot)
|
||||||
} catch {
|
} 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)
|
try? await cloudKit.deleteSharedCard(recordName: snapshot.recordName)
|
||||||
state = .saved
|
state = .saved
|
||||||
} catch {
|
} 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)
|
await store.load(recordName: recordName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,95 +1,244 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Displays a preview of the shared card with option to save to Contacts.
|
/// Displays a preview of the shared card with option to save to Contacts.
|
||||||
struct ClipCardPreview: View {
|
struct ClipCardPreview: View {
|
||||||
|
private struct ContactSection {
|
||||||
|
let title: String
|
||||||
|
let rows: [SharedCardSnapshot.ContactInfoRow]
|
||||||
|
}
|
||||||
|
|
||||||
let snapshot: SharedCardSnapshot
|
let snapshot: SharedCardSnapshot
|
||||||
let onSave: () -> Void
|
let onSave: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: ClipDesign.Spacing.xLarge) {
|
VStack(spacing: ClipDesign.Spacing.xLarge) {
|
||||||
Spacer()
|
Spacer(minLength: ClipDesign.Spacing.medium)
|
||||||
|
|
||||||
// Card content
|
previewCard
|
||||||
VStack(spacing: ClipDesign.Spacing.large) {
|
.frame(maxWidth: ClipDesign.Size.previewMaxWidth)
|
||||||
// Profile photo or placeholder
|
.padding(.horizontal, ClipDesign.Spacing.large)
|
||||||
if let photoData = snapshot.photoData,
|
|
||||||
let uiImage = UIImage(data: photoData) {
|
VStack(spacing: ClipDesign.Spacing.medium) {
|
||||||
Image(uiImage: uiImage)
|
// Save button
|
||||||
.resizable()
|
ClipPrimaryButton(
|
||||||
.scaledToFill()
|
title: String(localized: "Save to Contacts"),
|
||||||
.frame(width: ClipDesign.Size.avatarLarge, height: ClipDesign.Size.avatarLarge)
|
systemImage: "person.crop.circle.badge.plus",
|
||||||
.clipShape(.circle)
|
action: onSave
|
||||||
} else {
|
)
|
||||||
Image(systemName: "person.crop.circle.fill")
|
.accessibilityLabel(Text("Save \(snapshot.displayName) to contacts"))
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
// Get full app prompt
|
||||||
.frame(width: ClipDesign.Size.avatarLarge, height: ClipDesign.Size.avatarLarge)
|
Button {
|
||||||
.foregroundStyle(Color.Clip.secondaryText)
|
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)
|
Text(snapshot.displayName)
|
||||||
.font(.title)
|
.styled(.title2)
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Clip.text)
|
.foregroundStyle(Color.Clip.text)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
// Role and company
|
|
||||||
if !snapshot.role.isEmpty || !snapshot.company.isEmpty {
|
if !snapshot.role.isEmpty || !snapshot.company.isEmpty {
|
||||||
VStack(spacing: ClipDesign.Spacing.xSmall) {
|
VStack(spacing: ClipDesign.Spacing.xSmall) {
|
||||||
if !snapshot.role.isEmpty {
|
if !snapshot.role.isEmpty {
|
||||||
Text(snapshot.role)
|
Text(snapshot.role)
|
||||||
.font(.headline)
|
.styled(.headingEmphasis)
|
||||||
.foregroundStyle(Color.Clip.secondaryText)
|
.foregroundStyle(Color.Clip.text)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !snapshot.company.isEmpty {
|
if !snapshot.company.isEmpty {
|
||||||
Text(snapshot.company)
|
Text(snapshot.company)
|
||||||
.font(.subheadline)
|
.styled(.subheading)
|
||||||
.foregroundStyle(Color.Clip.secondaryText)
|
.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
|
if !contactSections.isEmpty {
|
||||||
Button(action: onSave) {
|
contactSectionsView
|
||||||
HStack(spacing: ClipDesign.Spacing.small) {
|
|
||||||
Image(systemName: "person.crop.circle.badge.plus")
|
Divider()
|
||||||
Text("Save to Contacts")
|
.overlay(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint))
|
||||||
}
|
}
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(Color.Clip.background)
|
Text(String(localized: "Shared business card"))
|
||||||
.frame(maxWidth: .infinity)
|
.styled(.caption)
|
||||||
.frame(height: ClipDesign.Size.buttonHeight)
|
.foregroundStyle(Color.Clip.secondaryText)
|
||||||
.background(Color.Clip.accent)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.clipShape(.capsule)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, ClipDesign.Spacing.xLarge)
|
.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
|
private var contactSectionsView: some View {
|
||||||
Button {
|
VStack(alignment: .leading, spacing: ClipDesign.Spacing.medium) {
|
||||||
openAppStore()
|
ForEach(contactSections, id: \.title) { section in
|
||||||
} label: {
|
VStack(alignment: .leading, spacing: ClipDesign.Spacing.xSmall) {
|
||||||
Text("Get the full app")
|
Text(section.title.uppercased())
|
||||||
.font(.subheadline)
|
.styled(.caption)
|
||||||
.foregroundStyle(Color.Clip.accent)
|
.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() {
|
private func openAppStore() {
|
||||||
// Open App Store page for the full app
|
if let url = URL(string: ClipDesign.URL.appStore) {
|
||||||
// Replace with actual App Store URL when available
|
|
||||||
if let url = URL(string: "https://apps.apple.com/app/id1234567890") {
|
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Error state shown when card fetch or save fails.
|
/// Error state shown when card fetch or save fails.
|
||||||
struct ClipErrorView: View {
|
struct ClipErrorView: View {
|
||||||
@ -14,26 +15,21 @@ struct ClipErrorView: View {
|
|||||||
.foregroundStyle(Color.Clip.error)
|
.foregroundStyle(Color.Clip.error)
|
||||||
|
|
||||||
VStack(spacing: ClipDesign.Spacing.small) {
|
VStack(spacing: ClipDesign.Spacing.small) {
|
||||||
Text("Something went wrong")
|
Text(String(localized: "Something went wrong"))
|
||||||
.font(.title2)
|
.styled(.title2)
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Clip.text)
|
.foregroundStyle(Color.Clip.text)
|
||||||
|
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.subheadline)
|
.styled(.subheading)
|
||||||
.foregroundStyle(Color.Clip.secondaryText)
|
.foregroundStyle(Color.Clip.secondaryText)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: onRetry) {
|
ClipPrimaryButton(
|
||||||
Text("Try Again")
|
title: String(localized: "Try Again"),
|
||||||
.font(.headline)
|
systemImage: "arrow.clockwise",
|
||||||
.foregroundStyle(Color.Clip.background)
|
action: onRetry
|
||||||
.frame(maxWidth: .infinity)
|
)
|
||||||
.frame(height: ClipDesign.Size.buttonHeight)
|
|
||||||
.background(Color.Clip.accent)
|
|
||||||
.clipShape(.capsule)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, ClipDesign.Spacing.xLarge)
|
.padding(.horizontal, ClipDesign.Spacing.xLarge)
|
||||||
.padding(.top, ClipDesign.Spacing.large)
|
.padding(.top, ClipDesign.Spacing.large)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Loading state view shown while fetching the card from CloudKit.
|
/// Loading state view shown while fetching the card from CloudKit.
|
||||||
struct ClipLoadingView: View {
|
struct ClipLoadingView: View {
|
||||||
@ -8,12 +9,12 @@ struct ClipLoadingView: View {
|
|||||||
.scaleEffect(1.5)
|
.scaleEffect(1.5)
|
||||||
.tint(Color.Clip.accent)
|
.tint(Color.Clip.accent)
|
||||||
|
|
||||||
Text("Loading card...")
|
Text(String(localized: "Loading card..."))
|
||||||
.font(.headline)
|
.styled(.heading)
|
||||||
.foregroundStyle(Color.Clip.text)
|
.foregroundStyle(Color.Clip.text)
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .combine)
|
.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 SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Success state shown after the contact has been saved.
|
/// Success state shown after the contact has been saved.
|
||||||
struct ClipSuccessView: View {
|
struct ClipSuccessView: View {
|
||||||
@ -17,29 +18,22 @@ struct ClipSuccessView: View {
|
|||||||
.animation(.spring(response: 0.5, dampingFraction: 0.6), value: showCheckmark)
|
.animation(.spring(response: 0.5, dampingFraction: 0.6), value: showCheckmark)
|
||||||
|
|
||||||
VStack(spacing: ClipDesign.Spacing.small) {
|
VStack(spacing: ClipDesign.Spacing.small) {
|
||||||
Text("Contact Saved!")
|
Text(String(localized: "Contact Saved!"))
|
||||||
.font(.title2)
|
.styled(.title2)
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Clip.text)
|
.foregroundStyle(Color.Clip.text)
|
||||||
|
|
||||||
Text("You can find this contact in your Contacts app.")
|
Text(String(localized: "You can find this contact in your Contacts app."))
|
||||||
.font(.subheadline)
|
.styled(.subheading)
|
||||||
.foregroundStyle(Color.Clip.secondaryText)
|
.foregroundStyle(Color.Clip.secondaryText)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open Contacts button
|
// Open Contacts button
|
||||||
Button {
|
ClipPrimaryButton(
|
||||||
openContacts()
|
title: String(localized: "Open Contacts"),
|
||||||
} label: {
|
systemImage: "person.crop.circle",
|
||||||
Text("Open Contacts")
|
action: openContacts
|
||||||
.font(.headline)
|
)
|
||||||
.foregroundStyle(Color.Clip.background)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: ClipDesign.Size.buttonHeight)
|
|
||||||
.background(Color.Clip.success)
|
|
||||||
.clipShape(.capsule)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, ClipDesign.Spacing.xLarge)
|
.padding(.horizontal, ClipDesign.Spacing.xLarge)
|
||||||
.padding(.top, ClipDesign.Spacing.large)
|
.padding(.top, ClipDesign.Spacing.large)
|
||||||
}
|
}
|
||||||
@ -48,11 +42,11 @@ struct ClipSuccessView: View {
|
|||||||
showCheckmark = true
|
showCheckmark = true
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityLabel(Text("Contact saved successfully"))
|
.accessibilityLabel(Text(String(localized: "Contact saved successfully")))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openContacts() {
|
private func openContacts() {
|
||||||
if let url = URL(string: "contacts://") {
|
if let url = URL(string: ClipDesign.URL.contactsScheme) {
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user