Compare commits
10 Commits
6c0fa315b2
...
fa3cb3e017
| Author | SHA1 | Date | |
|---|---|---|---|
| fa3cb3e017 | |||
| 2b679b0167 | |||
| 80439171bb | |||
| f4ea7c0ed7 | |||
| afa30f83c5 | |||
| 9059eda108 | |||
| 236871f5bc | |||
| eb01376b69 | |||
| 9233d779a3 | |||
| 97d8b648e4 |
11
BusinessCard.code-workspace
Normal file
11
BusinessCard.code-workspace
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../_Packages/Bedrock"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@ -8,8 +8,11 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; };
|
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; };
|
||||||
|
EA7568D32F4639EE006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D22F4639EE006196BB /* Bedrock */; };
|
||||||
|
EA7568D52F463A28006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D42F463A28006196BB /* Bedrock */; };
|
||||||
|
EA7568D72F463A2E006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D62F463A2E006196BB /* Bedrock */; };
|
||||||
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; };
|
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 = (CodeSignOnCopy, 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, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@ -80,9 +83,19 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
EA69EC522F3D5DDB00592220 /* Exceptions for "BusinessCardWatch Watch App" folder in "BusinessCardWatch Watch App" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Configuration/Watch.xcconfig,
|
||||||
|
);
|
||||||
|
target = EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */;
|
||||||
|
};
|
||||||
EA837E5C2F106CB500077F87 /* Exceptions for "BusinessCard" folder in "BusinessCard" target */ = {
|
EA837E5C2F106CB500077F87 /* Exceptions for "BusinessCard" folder in "BusinessCard" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
|
Configuration/Base.xcconfig,
|
||||||
|
Configuration/Debug.xcconfig,
|
||||||
|
Configuration/Release.xcconfig,
|
||||||
Info.plist,
|
Info.plist,
|
||||||
);
|
);
|
||||||
target = EA8379222F105F2600077F87 /* BusinessCard */;
|
target = EA8379222F105F2600077F87 /* BusinessCard */;
|
||||||
@ -90,6 +103,7 @@
|
|||||||
EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */ = {
|
EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
|
Configuration/Clip.xcconfig,
|
||||||
Info.plist,
|
Info.plist,
|
||||||
);
|
);
|
||||||
target = EACLIP0012F200000000004 /* BusinessCardClip */;
|
target = EACLIP0012F200000000004 /* BusinessCardClip */;
|
||||||
@ -117,6 +131,9 @@
|
|||||||
};
|
};
|
||||||
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */ = {
|
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
EA69EC522F3D5DDB00592220 /* Exceptions for "BusinessCardWatch Watch App" folder in "BusinessCardWatch Watch App" target */,
|
||||||
|
);
|
||||||
path = "BusinessCardWatch Watch App";
|
path = "BusinessCardWatch Watch App";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -135,6 +152,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
EA7568D32F4639EE006196BB /* Bedrock in Frameworks */,
|
||||||
EA837E672F107D6800077F87 /* Bedrock in Frameworks */,
|
EA837E672F107D6800077F87 /* Bedrock in Frameworks */,
|
||||||
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */,
|
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */,
|
||||||
);
|
);
|
||||||
@ -158,6 +176,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
EA7568D72F463A2E006196BB /* Bedrock in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -165,6 +184,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
EA7568D52F463A28006196BB /* Bedrock in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -239,6 +259,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
EA837E662F107D6800077F87 /* Bedrock */,
|
EA837E662F107D6800077F87 /* Bedrock */,
|
||||||
EA69DC812F3C199C00592220 /* Bedrock */,
|
EA69DC812F3C199C00592220 /* Bedrock */,
|
||||||
|
EA7568D22F4639EE006196BB /* Bedrock */,
|
||||||
);
|
);
|
||||||
productName = BusinessCard;
|
productName = BusinessCard;
|
||||||
productReference = EA8379232F105F2600077F87 /* Business Card.app */;
|
productReference = EA8379232F105F2600077F87 /* Business Card.app */;
|
||||||
@ -307,6 +328,7 @@
|
|||||||
);
|
);
|
||||||
name = "BusinessCardWatch Watch App";
|
name = "BusinessCardWatch Watch App";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
EA7568D62F463A2E006196BB /* Bedrock */,
|
||||||
);
|
);
|
||||||
productName = "BusinessCardWatch Watch App";
|
productName = "BusinessCardWatch Watch App";
|
||||||
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
|
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
|
||||||
@ -329,6 +351,7 @@
|
|||||||
);
|
);
|
||||||
name = BusinessCardClip;
|
name = BusinessCardClip;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
EA7568D42F463A28006196BB /* Bedrock */,
|
||||||
);
|
);
|
||||||
productName = BusinessCardClip;
|
productName = BusinessCardClip;
|
||||||
productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
|
productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
|
||||||
@ -342,7 +365,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2600;
|
LastSwiftUpdateCheck = 2600;
|
||||||
LastUpgradeCheck = 2600;
|
LastUpgradeCheck = 2630;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
EA8379222F105F2600077F87 = {
|
EA8379222F105F2600077F87 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
@ -375,7 +398,7 @@
|
|||||||
mainGroup = EA83791A2F105F2600077F87;
|
mainGroup = EA83791A2F105F2600077F87;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */,
|
EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = EA8379242F105F2600077F87 /* Products */;
|
productRefGroup = EA8379242F105F2600077F87 /* Products */;
|
||||||
@ -497,6 +520,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@ -551,6 +575,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@ -562,6 +587,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@ -609,6 +635,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
@ -634,12 +661,13 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@ -671,12 +699,13 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@ -783,7 +812,7 @@
|
|||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch;
|
INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@ -817,7 +846,7 @@
|
|||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch;
|
INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@ -848,22 +877,23 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = BusinessCardClip/BusinessCardClip.entitlements;
|
CODE_SIGN_ENTITLEMENTS = BusinessCardClip/BusinessCardClip.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusinessCardClip/Info.plist;
|
INFOPLIST_FILE = BusinessCardClip/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard;
|
INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -886,22 +916,23 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = BusinessCardClip/BusinessCardClip.entitlements;
|
CODE_SIGN_ENTITLEMENTS = BusinessCardClip/BusinessCardClip.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusinessCardClip/Info.plist;
|
INFOPLIST_FILE = BusinessCardClip/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard;
|
INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -976,9 +1007,9 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */ = {
|
EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = ../Bedrock;
|
relativePath = ../_Packages/Bedrock;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
@ -987,6 +1018,20 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Bedrock;
|
productName = Bedrock;
|
||||||
};
|
};
|
||||||
|
EA7568D22F4639EE006196BB /* Bedrock */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Bedrock;
|
||||||
|
};
|
||||||
|
EA7568D42F463A28006196BB /* Bedrock */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */;
|
||||||
|
productName = Bedrock;
|
||||||
|
};
|
||||||
|
EA7568D62F463A2E006196BB /* Bedrock */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */;
|
||||||
|
productName = Bedrock;
|
||||||
|
};
|
||||||
EA837E662F107D6800077F87 /* Bedrock */ = {
|
EA837E662F107D6800077F87 /* Bedrock */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Bedrock;
|
productName = Bedrock;
|
||||||
|
|||||||
@ -0,0 +1,103 @@
|
|||||||
|
<?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 = "NO">
|
||||||
|
</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>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCAppClipURL"
|
||||||
|
value = "https://topdoglabs.com/bc?id=FE866C3C-EEBC-4A66-B176-11D0F01E28DF"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</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>0</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,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.250",
|
||||||
|
"green" : "0.750",
|
||||||
|
"red" : "0.950"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.140",
|
||||||
|
"green" : "0.120",
|
||||||
|
"red" : "0.120"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.550",
|
||||||
|
"green" : "0.650",
|
||||||
|
"red" : "0.200"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.280",
|
||||||
|
"green" : "0.330",
|
||||||
|
"red" : "0.950"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.400",
|
||||||
|
"green" : "0.330",
|
||||||
|
"red" : "0.290"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.900",
|
||||||
|
"green" : "0.890",
|
||||||
|
"red" : "0.890"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.340",
|
||||||
|
"green" : "0.820",
|
||||||
|
"red" : "0.980"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "1.000",
|
||||||
|
"red" : "1.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.278",
|
||||||
|
"green" : "0.329",
|
||||||
|
"red" : "0.949"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.150",
|
||||||
|
"green" : "0.180",
|
||||||
|
"red" : "0.650"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.280",
|
||||||
|
"green" : "0.750",
|
||||||
|
"red" : "0.980"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.330",
|
||||||
|
"green" : "0.350",
|
||||||
|
"red" : "0.950"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.310",
|
||||||
|
"green" : "0.370",
|
||||||
|
"red" : "0.130"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.340",
|
||||||
|
"green" : "0.820",
|
||||||
|
"red" : "0.730"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.220",
|
||||||
|
"green" : "0.160",
|
||||||
|
"red" : "0.120"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.560",
|
||||||
|
"green" : "0.450",
|
||||||
|
"red" : "0.080"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.520",
|
||||||
|
"green" : "0.270",
|
||||||
|
"red" : "0.560"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.730",
|
||||||
|
"green" : "0.680",
|
||||||
|
"red" : "0.950"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.680",
|
||||||
|
"green" : "0.830",
|
||||||
|
"red" : "0.930"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.500",
|
||||||
|
"green" : "0.440",
|
||||||
|
"red" : "0.380"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.620",
|
||||||
|
"green" : "0.360",
|
||||||
|
"red" : "0.420"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,9 +5,9 @@
|
|||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.278",
|
"blue" : "0x46",
|
||||||
"green" : "0.329",
|
"green" : "0x53",
|
||||||
"red" : "0.949"
|
"red" : "0xF1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.350",
|
||||||
|
"green" : "0.820",
|
||||||
|
"red" : "0.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.700",
|
||||||
|
"green" : "0.400",
|
||||||
|
"red" : "0.260"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.130",
|
||||||
|
"green" : "0.130",
|
||||||
|
"red" : "0.130"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.420",
|
||||||
|
"green" : "0.190",
|
||||||
|
"red" : "0.880"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.710",
|
||||||
|
"green" : "0.470",
|
||||||
|
"red" : "0.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.890",
|
||||||
|
"green" : "0.630",
|
||||||
|
"red" : "0.160"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.000",
|
||||||
|
"green" : "0.000",
|
||||||
|
"red" : "0.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.000",
|
||||||
|
"green" : "0.000",
|
||||||
|
"red" : "0.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.950",
|
||||||
|
"green" : "0.630",
|
||||||
|
"red" : "0.110"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.790",
|
||||||
|
"green" : "0.530",
|
||||||
|
"red" : "0.220"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.380",
|
||||||
|
"green" : "0.680",
|
||||||
|
"red" : "0.150"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import AppIntents
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
@main
|
@main
|
||||||
|
|||||||
@ -66,6 +66,6 @@ enum AppIdentifiers {
|
|||||||
|
|
||||||
/// Generates an App Clip invocation URL for a shared card record.
|
/// Generates an App Clip invocation URL for a shared card record.
|
||||||
static func appClipURL(recordName: String) -> URL? {
|
static func appClipURL(recordName: String) -> URL? {
|
||||||
URL(string: "https://\(appClipDomain)/appclip/businesscard?id=\(recordName)")
|
URL(string: "https://\(appClipDomain)/bc?id=\(recordName)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,13 +15,13 @@ extension Color {
|
|||||||
enum Branding {
|
enum Branding {
|
||||||
/// Primary gradient color (warm coral/red).
|
/// Primary gradient color (warm coral/red).
|
||||||
/// Must match LaunchBackground.colorset exactly to prevent flash.
|
/// Must match LaunchBackground.colorset exactly to prevent flash.
|
||||||
static let primary = Color(red: 0.949, green: 0.329, blue: 0.278)
|
static let primary = Color("BrandingPrimary")
|
||||||
|
|
||||||
/// Secondary gradient color (darker red).
|
/// Secondary gradient color (darker red).
|
||||||
static let secondary = Color(red: 0.65, green: 0.18, blue: 0.15)
|
static let secondary = Color("BrandingSecondary")
|
||||||
|
|
||||||
/// Accent color for icons and highlights.
|
/// Accent color for icons and highlights.
|
||||||
static let accent = Color.white
|
static let accent = Color("BrandingAccent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ extension LaunchScreenConfig {
|
|||||||
primaryColor: Color.Branding.primary,
|
primaryColor: Color.Branding.primary,
|
||||||
secondaryColor: Color.Branding.secondary,
|
secondaryColor: Color.Branding.secondary,
|
||||||
accentColor: Color.Branding.accent,
|
accentColor: Color.Branding.accent,
|
||||||
titleColor: .white,
|
titleColor: Color.Branding.accent,
|
||||||
iconSize: 52,
|
iconSize: 52,
|
||||||
titleSize: 32,
|
titleSize: 32,
|
||||||
iconSpacing: 12,
|
iconSpacing: 12,
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import SwiftUI
|
|||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
public enum BusinessCardAccentColors: AccentColorProvider {
|
public enum BusinessCardAccentColors: AccentColorProvider {
|
||||||
public static let primary = Color(red: 0.95, green: 0.33, blue: 0.28)
|
public static let primary = Color.Accent.red
|
||||||
public static let light = Color(red: 0.98, green: 0.50, blue: 0.45)
|
public static let light = Color("CardPaletteRose")
|
||||||
public static let dark = Color(red: 0.75, green: 0.25, blue: 0.22)
|
public static let dark = Color("BrandingSecondary")
|
||||||
public static let secondary = Color(red: 0.12, green: 0.12, blue: 0.14)
|
public static let secondary = Color.Accent.ink
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import SwiftUI
|
|||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
public enum BusinessCardButtonColors: ButtonColorProvider {
|
public enum BusinessCardButtonColors: ButtonColorProvider {
|
||||||
public static let primaryLight = Color(red: 0.98, green: 0.45, blue: 0.40)
|
public static let primaryLight = Color("CardPaletteRose")
|
||||||
public static let primaryDark = Color(red: 0.85, green: 0.28, blue: 0.24)
|
public static let primaryDark = Color("BrandingSecondary")
|
||||||
public static let secondary = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.subtle)
|
public static let secondary = Color.AppBackground.accent.opacity(Design.Opacity.subtle)
|
||||||
public static let destructive = Color.red.opacity(Design.Opacity.heavy)
|
public static let destructive = Color.Accent.red.opacity(Design.Opacity.heavy)
|
||||||
public static let cancelText = Color(red: 0.32, green: 0.34, blue: 0.40)
|
public static let cancelText = Color.Text.secondary
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import Bedrock
|
|||||||
|
|
||||||
public enum BusinessCardInteractiveColors: InteractiveColorProvider {
|
public enum BusinessCardInteractiveColors: InteractiveColorProvider {
|
||||||
public static let selected = BusinessCardAccentColors.primary.opacity(Design.Opacity.selection)
|
public static let selected = BusinessCardAccentColors.primary.opacity(Design.Opacity.selection)
|
||||||
public static let hover = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.subtle)
|
public static let hover = Color.AppBackground.accent.opacity(Design.Opacity.subtle)
|
||||||
public static let pressed = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.hint)
|
public static let pressed = Color.AppBackground.accent.opacity(Design.Opacity.hint)
|
||||||
public static let focus = BusinessCardAccentColors.light
|
public static let focus = BusinessCardAccentColors.light
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import SwiftUI
|
|||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
public enum BusinessCardStatusColors: StatusColorProvider {
|
public enum BusinessCardStatusColors: StatusColorProvider {
|
||||||
public static let success = Color(red: 0.2, green: 0.75, blue: 0.4)
|
public static let success = Color.Accent.mint
|
||||||
public static let warning = Color(red: 0.95, green: 0.75, blue: 0.25)
|
public static let warning = Color.Accent.gold
|
||||||
public static let error = Color(red: 0.9, green: 0.3, blue: 0.3)
|
public static let error = Color.Accent.red
|
||||||
public static let info = Color(red: 0.3, green: 0.6, blue: 0.9)
|
public static let info = Color("SocialTwitter")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
// MARK: - Device Size Tier
|
// MARK: - Device Size Tier
|
||||||
@ -20,7 +21,10 @@ enum DeviceSizeTier {
|
|||||||
|
|
||||||
/// The current device's size tier based on screen dimensions.
|
/// The current device's size tier based on screen dimensions.
|
||||||
static var current: DeviceSizeTier {
|
static var current: DeviceSizeTier {
|
||||||
let bounds = UIScreen.main.bounds
|
let bounds = UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
.first?.screen.bounds
|
||||||
|
?? CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
let height = max(bounds.width, bounds.height)
|
let height = max(bounds.width, bounds.height)
|
||||||
let width = min(bounds.width, bounds.height)
|
let width = min(bounds.width, bounds.height)
|
||||||
|
|
||||||
@ -91,6 +95,42 @@ extension Design {
|
|||||||
DeviceSizeTier.isTablet ? 500 : nil
|
DeviceSizeTier.isTablet ? 500 : nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Contacts feature constants (list behavior, row layout, note UI).
|
||||||
|
enum Contacts {
|
||||||
|
static let detailFieldIconWidth: CGFloat = 28
|
||||||
|
static let detailHeaderAvatarSize: CGFloat = 92
|
||||||
|
static let sectionIndexMinContacts = 20
|
||||||
|
static let sectionIndexMinSections = 6
|
||||||
|
static let sectionIndexLetterSpacing: CGFloat = 2
|
||||||
|
static let sectionIndexLetterWidth: CGFloat = 16
|
||||||
|
static let sectionIndexLetterHeight: CGFloat = 12
|
||||||
|
static let sectionIndexScrollAnimationDuration: Double = 0.2
|
||||||
|
static let notePreviewLineLimit = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image color extraction constants.
|
||||||
|
enum ColorExtraction {
|
||||||
|
static let sampleSize = 50
|
||||||
|
static let rgbaBytesPerPixel = 4
|
||||||
|
static let alphaVisibilityThreshold: UInt8 = 128
|
||||||
|
static let minBrightness = 30
|
||||||
|
static let maxBrightness = 225
|
||||||
|
static let bucketQuantizationStep = 32
|
||||||
|
static let rgbDenominator = 255.0
|
||||||
|
static let colorSimilarityThreshold = 0.15
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card theme math constants.
|
||||||
|
enum ThemeMath {
|
||||||
|
static let luminanceRedWeight = 0.299
|
||||||
|
static let luminanceGreenWeight = 0.587
|
||||||
|
static let luminanceBlueWeight = 0.114
|
||||||
|
static let requiresDarkTextThreshold = 0.5
|
||||||
|
static let darkThemeLuminanceThreshold = 0.3
|
||||||
|
static let customLightenAmount = 0.15
|
||||||
|
static let customDarkenAmount = 0.12
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shadow Extensions
|
// MARK: - Shadow Extensions
|
||||||
@ -118,27 +158,27 @@ extension Color {
|
|||||||
// MARK: - Card Theme Palette
|
// MARK: - Card Theme Palette
|
||||||
|
|
||||||
enum CardPalette {
|
enum CardPalette {
|
||||||
static let coral = Color(red: 0.95, green: 0.35, blue: 0.33)
|
static let coral = Color("CardPaletteCoral")
|
||||||
static let midnight = Color(red: 0.12, green: 0.16, blue: 0.22)
|
static let midnight = Color("CardPaletteMidnight")
|
||||||
static let ocean = Color(red: 0.08, green: 0.45, blue: 0.56)
|
static let ocean = Color("CardPaletteOcean")
|
||||||
static let lime = Color(red: 0.73, green: 0.82, blue: 0.34)
|
static let lime = Color("CardPaletteLime")
|
||||||
static let violet = Color(red: 0.42, green: 0.36, blue: 0.62)
|
static let violet = Color("CardPaletteViolet")
|
||||||
static let forest = Color(red: 0.13, green: 0.37, blue: 0.31)
|
static let forest = Color("CardPaletteForest")
|
||||||
static let rose = Color(red: 0.95, green: 0.68, blue: 0.73)
|
static let rose = Color("CardPaletteRose")
|
||||||
static let slate = Color(red: 0.38, green: 0.44, blue: 0.50)
|
static let slate = Color("CardPaletteSlate")
|
||||||
static let amber = Color(red: 0.98, green: 0.75, blue: 0.28)
|
static let amber = Color("CardPaletteAmber")
|
||||||
static let plum = Color(red: 0.56, green: 0.27, blue: 0.52)
|
static let plum = Color("CardPalettePlum")
|
||||||
static let sand = Color(red: 0.93, green: 0.83, blue: 0.68)
|
static let sand = Color("CardPaletteSand")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - App Accent Colors
|
// MARK: - App Accent Colors
|
||||||
|
|
||||||
enum AppAccent {
|
enum AppAccent {
|
||||||
static let red = Color(red: 0.95, green: 0.33, blue: 0.28)
|
static let red = Color("AppAccentRed")
|
||||||
static let gold = Color(red: 0.95, green: 0.75, blue: 0.25)
|
static let gold = Color("AppAccentGold")
|
||||||
static let mint = Color(red: 0.2, green: 0.65, blue: 0.55)
|
static let mint = Color("AppAccentMint")
|
||||||
static let ink = Color(red: 0.12, green: 0.12, blue: 0.14)
|
static let ink = Color("AppAccentInk")
|
||||||
static let slate = Color(red: 0.29, green: 0.33, blue: 0.4)
|
static let slate = Color("AppAccentSlate")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - App Text Colors
|
// MARK: - App Text Colors
|
||||||
@ -153,8 +193,8 @@ extension Color {
|
|||||||
// MARK: - Badge Colors
|
// MARK: - Badge Colors
|
||||||
|
|
||||||
enum Badge {
|
enum Badge {
|
||||||
static let star = Color(red: 0.98, green: 0.82, blue: 0.34)
|
static let star = Color("BadgeStar")
|
||||||
static let neutral = Color(red: 0.89, green: 0.89, blue: 0.9)
|
static let neutral = Color("BadgeNeutral")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Share Sheet Theme
|
// MARK: - Share Sheet Theme
|
||||||
@ -170,17 +210,17 @@ extension Color {
|
|||||||
// MARK: - Social Media Brand Colors
|
// MARK: - Social Media Brand Colors
|
||||||
|
|
||||||
enum Social {
|
enum Social {
|
||||||
static let linkedIn = Color(red: 0.0, green: 0.47, blue: 0.71)
|
static let linkedIn = Color("SocialLinkedIn")
|
||||||
static let twitter = Color(red: 0.11, green: 0.63, blue: 0.95)
|
static let twitter = Color("SocialTwitter")
|
||||||
static let instagram = Color(red: 0.88, green: 0.19, blue: 0.42)
|
static let instagram = Color("SocialInstagram")
|
||||||
static let facebook = Color(red: 0.26, green: 0.40, blue: 0.70)
|
static let facebook = Color("SocialFacebook")
|
||||||
static let tiktok = Color(red: 0.0, green: 0.0, blue: 0.0)
|
static let tiktok = Color("SocialTikTok")
|
||||||
static let github = Color(red: 0.13, green: 0.13, blue: 0.13)
|
static let github = Color("SocialGitHub")
|
||||||
static let threads = Color(red: 0.0, green: 0.0, blue: 0.0)
|
static let threads = Color("SocialThreads")
|
||||||
static let telegram = Color(red: 0.16, green: 0.63, blue: 0.89)
|
static let telegram = Color("SocialTelegram")
|
||||||
static let whatsapp = Color(red: 0.15, green: 0.68, blue: 0.38)
|
static let whatsapp = Color("SocialWhatsApp")
|
||||||
static let venmo = Color(red: 0.22, green: 0.53, blue: 0.79)
|
static let venmo = Color("SocialVenmo")
|
||||||
static let cashApp = Color(red: 0.0, green: 0.82, blue: 0.35)
|
static let cashApp = Color("SocialCashApp")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,11 @@ import SwiftData
|
|||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class AppSettings {
|
final class AppSettings {
|
||||||
var id: UUID
|
var id: UUID = UUID()
|
||||||
var preferredShareActionRawValue: String
|
var preferredShareActionRawValue: String = "shareSheet"
|
||||||
var defaultFollowUpPresetRawValue: String
|
var defaultFollowUpPresetRawValue: String = "none"
|
||||||
var createdAt: Date
|
var createdAt: Date = Date()
|
||||||
var updatedAt: Date
|
var updatedAt: Date = Date()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
|
|||||||
@ -4,31 +4,31 @@ import SwiftUI
|
|||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class BusinessCard {
|
final class BusinessCard {
|
||||||
var id: UUID
|
var id: UUID = UUID()
|
||||||
var role: String
|
var role: String = ""
|
||||||
var company: String
|
var company: String = ""
|
||||||
var label: String
|
var label: String = "Work"
|
||||||
var isDefault: Bool
|
var isDefault: Bool = false
|
||||||
var themeName: String
|
var themeName: String = "Coral"
|
||||||
var layoutStyleRawValue: String
|
var layoutStyleRawValue: String = "stacked"
|
||||||
var headerLayoutRawValue: String
|
var headerLayoutRawValue: String = "profileBanner"
|
||||||
var avatarSystemName: String
|
var avatarSystemName: String = "person.crop.circle"
|
||||||
var createdAt: Date
|
var createdAt: Date = Date()
|
||||||
var updatedAt: Date
|
var updatedAt: Date = Date()
|
||||||
|
|
||||||
// Enhanced profile fields
|
// Enhanced profile fields
|
||||||
var prefix: String
|
var prefix: String = ""
|
||||||
var firstName: String
|
var firstName: String = ""
|
||||||
var middleName: String
|
var middleName: String = ""
|
||||||
var lastName: String
|
var lastName: String = ""
|
||||||
var suffix: String
|
var suffix: String = ""
|
||||||
var maidenName: String
|
var maidenName: String = ""
|
||||||
var preferredName: String
|
var preferredName: String = ""
|
||||||
var pronouns: String
|
var pronouns: String = ""
|
||||||
var department: String
|
var department: String = ""
|
||||||
var headline: String
|
var headline: String = ""
|
||||||
var bio: String
|
var bio: String = ""
|
||||||
var accreditations: String
|
var accreditations: String = ""
|
||||||
|
|
||||||
// Profile photo stored as Data (JPEG)
|
// Profile photo stored as Data (JPEG)
|
||||||
@Attribute(.externalStorage) var photoData: Data?
|
@Attribute(.externalStorage) var photoData: Data?
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Card theme configuration supporting both preset themes and custom colors.
|
/// Card theme configuration supporting both preset themes and custom colors.
|
||||||
///
|
///
|
||||||
@ -88,8 +89,11 @@ struct CardTheme: Identifiable, Hashable, Sendable {
|
|||||||
var requiresDarkText: Bool {
|
var requiresDarkText: Bool {
|
||||||
if let rgb = customRGB {
|
if let rgb = customRGB {
|
||||||
// Calculate perceived luminance for custom colors
|
// Calculate perceived luminance for custom colors
|
||||||
let luminance = 0.299 * rgb.0 + 0.587 * rgb.1 + 0.114 * rgb.2
|
let luminance =
|
||||||
return luminance > 0.5
|
Design.ThemeMath.luminanceRedWeight * rgb.0
|
||||||
|
+ Design.ThemeMath.luminanceGreenWeight * rgb.1
|
||||||
|
+ Design.ThemeMath.luminanceBlueWeight * rgb.2
|
||||||
|
return luminance > Design.ThemeMath.requiresDarkTextThreshold
|
||||||
}
|
}
|
||||||
guard let preset else { return false }
|
guard let preset else { return false }
|
||||||
switch preset {
|
switch preset {
|
||||||
@ -100,106 +104,82 @@ struct CardTheme: Identifiable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - RGB Values
|
|
||||||
|
|
||||||
private var primaryRGB: (Double, Double, Double) {
|
|
||||||
if let rgb = customRGB { return rgb }
|
|
||||||
guard let preset else { return (0.95, 0.35, 0.33) }
|
|
||||||
switch preset {
|
|
||||||
case .coral: return (0.95, 0.35, 0.33)
|
|
||||||
case .midnight: return (0.12, 0.16, 0.22)
|
|
||||||
case .ocean: return (0.08, 0.45, 0.56)
|
|
||||||
case .lime: return (0.73, 0.82, 0.34)
|
|
||||||
case .violet: return (0.42, 0.36, 0.62)
|
|
||||||
case .forest: return (0.13, 0.37, 0.31)
|
|
||||||
case .rose: return (0.95, 0.68, 0.73)
|
|
||||||
case .slate: return (0.38, 0.44, 0.50)
|
|
||||||
case .amber: return (0.98, 0.75, 0.28)
|
|
||||||
case .plum: return (0.56, 0.27, 0.52)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var secondaryRGB: (Double, Double, Double) {
|
|
||||||
if let rgb = customRGB {
|
|
||||||
// Calculate luminance to determine if we should lighten or darken
|
|
||||||
let luminance = 0.299 * rgb.0 + 0.587 * rgb.1 + 0.114 * rgb.2
|
|
||||||
|
|
||||||
if luminance < 0.3 {
|
|
||||||
// Dark color: lighten uniformly to avoid color shifts
|
|
||||||
let lightenAmount = 0.15
|
|
||||||
return (
|
|
||||||
min(1.0, rgb.0 + lightenAmount),
|
|
||||||
min(1.0, rgb.1 + lightenAmount),
|
|
||||||
min(1.0, rgb.2 + lightenAmount)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Light color: darken uniformly
|
|
||||||
let darkenAmount = 0.12
|
|
||||||
return (
|
|
||||||
max(0.0, rgb.0 - darkenAmount),
|
|
||||||
max(0.0, rgb.1 - darkenAmount),
|
|
||||||
max(0.0, rgb.2 - darkenAmount)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let preset else { return (0.93, 0.83, 0.68) }
|
|
||||||
switch preset {
|
|
||||||
case .coral: return (0.93, 0.83, 0.68)
|
|
||||||
case .midnight: return (0.29, 0.33, 0.4)
|
|
||||||
case .ocean: return (0.2, 0.65, 0.55)
|
|
||||||
case .lime: return (0.93, 0.83, 0.68)
|
|
||||||
case .violet: return (0.29, 0.33, 0.4)
|
|
||||||
case .forest: return (0.22, 0.52, 0.42)
|
|
||||||
case .rose: return (0.98, 0.85, 0.88)
|
|
||||||
case .slate: return (0.52, 0.58, 0.64)
|
|
||||||
case .amber: return (0.99, 0.88, 0.55)
|
|
||||||
case .plum: return (0.72, 0.45, 0.68)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var accentRGB: (Double, Double, Double) {
|
|
||||||
if customRGB != nil {
|
|
||||||
// For custom colors, use a contrasting accent (gold or dark)
|
|
||||||
return requiresDarkText ? (0.12, 0.12, 0.14) : (0.95, 0.75, 0.25)
|
|
||||||
}
|
|
||||||
guard let preset else { return (0.95, 0.33, 0.28) }
|
|
||||||
switch preset {
|
|
||||||
case .coral: return (0.95, 0.33, 0.28)
|
|
||||||
case .midnight: return (0.95, 0.75, 0.25)
|
|
||||||
case .ocean: return (0.95, 0.75, 0.25)
|
|
||||||
case .lime: return (0.12, 0.12, 0.14)
|
|
||||||
case .violet: return (0.95, 0.75, 0.25)
|
|
||||||
case .forest: return (0.95, 0.75, 0.25)
|
|
||||||
case .rose: return (0.75, 0.25, 0.35)
|
|
||||||
case .slate: return (0.95, 0.75, 0.25)
|
|
||||||
case .amber: return (0.12, 0.12, 0.14)
|
|
||||||
case .plum: return (0.95, 0.75, 0.25)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textRGB: (Double, Double, Double) {
|
|
||||||
requiresDarkText
|
|
||||||
? (0.14, 0.14, 0.17) // Dark text for light backgrounds
|
|
||||||
: (0.98, 0.98, 0.98) // Light text for dark backgrounds
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Colors (MainActor)
|
// MARK: - Colors (MainActor)
|
||||||
|
|
||||||
@MainActor var primaryColor: Color {
|
@MainActor var primaryColor: Color {
|
||||||
Color(red: primaryRGB.0, green: primaryRGB.1, blue: primaryRGB.2)
|
if let rgb = customRGB {
|
||||||
|
return Color(red: rgb.0, green: rgb.1, blue: rgb.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let preset else { return Color.CardPalette.coral }
|
||||||
|
switch preset {
|
||||||
|
case .coral: return Color.CardPalette.coral
|
||||||
|
case .midnight: return Color.CardPalette.midnight
|
||||||
|
case .ocean: return Color.CardPalette.ocean
|
||||||
|
case .lime: return Color.CardPalette.lime
|
||||||
|
case .violet: return Color.CardPalette.violet
|
||||||
|
case .forest: return Color.CardPalette.forest
|
||||||
|
case .rose: return Color.CardPalette.rose
|
||||||
|
case .slate: return Color.CardPalette.slate
|
||||||
|
case .amber: return Color.CardPalette.amber
|
||||||
|
case .plum: return Color.CardPalette.plum
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var secondaryColor: Color {
|
@MainActor var secondaryColor: Color {
|
||||||
Color(red: secondaryRGB.0, green: secondaryRGB.1, blue: secondaryRGB.2)
|
if let rgb = customRGB {
|
||||||
|
// Calculate luminance to determine if we should lighten or darken.
|
||||||
|
let luminance =
|
||||||
|
Design.ThemeMath.luminanceRedWeight * rgb.0
|
||||||
|
+ Design.ThemeMath.luminanceGreenWeight * rgb.1
|
||||||
|
+ Design.ThemeMath.luminanceBlueWeight * rgb.2
|
||||||
|
if luminance < Design.ThemeMath.darkThemeLuminanceThreshold {
|
||||||
|
return Color(
|
||||||
|
red: min(1.0, rgb.0 + Design.ThemeMath.customLightenAmount),
|
||||||
|
green: min(1.0, rgb.1 + Design.ThemeMath.customLightenAmount),
|
||||||
|
blue: min(1.0, rgb.2 + Design.ThemeMath.customLightenAmount)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Color(
|
||||||
|
red: max(0.0, rgb.0 - Design.ThemeMath.customDarkenAmount),
|
||||||
|
green: max(0.0, rgb.1 - Design.ThemeMath.customDarkenAmount),
|
||||||
|
blue: max(0.0, rgb.2 - Design.ThemeMath.customDarkenAmount)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let preset else { return Color.CardPalette.sand }
|
||||||
|
switch preset {
|
||||||
|
case .coral, .lime: return Color.CardPalette.sand
|
||||||
|
case .midnight, .violet: return Color.Accent.slate
|
||||||
|
case .ocean: return Color.Accent.mint
|
||||||
|
case .forest: return Color("SocialWhatsApp")
|
||||||
|
case .rose: return Color("CardPaletteRose")
|
||||||
|
case .slate: return Color("CardPaletteSlate")
|
||||||
|
case .amber: return Color("CardPaletteSand")
|
||||||
|
case .plum: return Color("CardPalettePlum")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var accentColor: Color {
|
@MainActor var accentColor: Color {
|
||||||
Color(red: accentRGB.0, green: accentRGB.1, blue: accentRGB.2)
|
if customRGB != nil {
|
||||||
|
return requiresDarkText ? Color.Accent.ink : Color.Accent.gold
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let preset else { return Color.Accent.red }
|
||||||
|
switch preset {
|
||||||
|
case .lime, .amber:
|
||||||
|
return Color.Accent.ink
|
||||||
|
case .rose:
|
||||||
|
return Color("BrandingSecondary")
|
||||||
|
case .coral, .midnight, .ocean, .violet, .forest, .slate, .plum:
|
||||||
|
return Color.Accent.gold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The appropriate text color for content displayed on this theme's background.
|
/// The appropriate text color for content displayed on this theme's background.
|
||||||
@MainActor var textColor: Color {
|
@MainActor var textColor: Color {
|
||||||
Color(red: textRGB.0, green: textRGB.1, blue: textRGB.2)
|
requiresDarkText ? Color.Text.primary : Color.AppText.inverted
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hashable & Equatable
|
// MARK: - Hashable & Equatable
|
||||||
|
|||||||
@ -3,27 +3,27 @@ import SwiftData
|
|||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class Contact {
|
final class Contact {
|
||||||
var id: UUID
|
var id: UUID = UUID()
|
||||||
var name: String
|
var name: String = ""
|
||||||
var role: String
|
var role: String = ""
|
||||||
var company: String
|
var company: String = ""
|
||||||
var avatarSystemName: String
|
var avatarSystemName: String = "person.crop.circle"
|
||||||
var lastSharedDate: Date
|
var lastSharedDate: Date = Date()
|
||||||
var cardLabel: String
|
var cardLabel: String = "Work"
|
||||||
|
|
||||||
// Contact annotations
|
// Contact annotations
|
||||||
var notes: String
|
var notes: String = ""
|
||||||
var tags: String // Comma-separated tags
|
var tags: String = "" // Comma-separated tags
|
||||||
var followUpDate: Date?
|
var followUpDate: Date?
|
||||||
var email: String // Legacy single email (kept for migration/fallback)
|
var email: String = "" // Legacy single email (kept for migration/fallback)
|
||||||
var phone: String // Legacy single phone (kept for migration/fallback)
|
var phone: String = "" // Legacy single phone (kept for migration/fallback)
|
||||||
|
|
||||||
// Multiple contact fields (phones, emails, links with labels)
|
// Multiple contact fields (phones, emails, links with labels)
|
||||||
@Relationship(deleteRule: .cascade, inverse: \ContactField.contact)
|
@Relationship(deleteRule: .cascade, inverse: \ContactField.contact)
|
||||||
var contactFields: [ContactField]?
|
var contactFields: [ContactField]?
|
||||||
|
|
||||||
// If this is a received card (scanned from someone else)
|
// If this is a received card (scanned from someone else)
|
||||||
var isReceivedCard: Bool
|
var isReceivedCard: Bool = false
|
||||||
|
|
||||||
// Profile photo
|
// Profile photo
|
||||||
@Attribute(.externalStorage) var photoData: Data?
|
@Attribute(.externalStorage) var photoData: Data?
|
||||||
|
|||||||
@ -117,7 +117,7 @@ extension ContactFieldType {
|
|||||||
id: "phone",
|
id: "phone",
|
||||||
displayName: String(localized: "Phone Number"),
|
displayName: String(localized: "Phone Number"),
|
||||||
systemImage: "phone.fill",
|
systemImage: "phone.fill",
|
||||||
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
|
iconColor: Color.Text.secondary,
|
||||||
category: .contact,
|
category: .contact,
|
||||||
valueLabel: String(localized: "Phone Number"),
|
valueLabel: String(localized: "Phone Number"),
|
||||||
valuePlaceholder: "+1 (555) 123-4567",
|
valuePlaceholder: "+1 (555) 123-4567",
|
||||||
@ -134,7 +134,7 @@ extension ContactFieldType {
|
|||||||
id: "email",
|
id: "email",
|
||||||
displayName: String(localized: "Email"),
|
displayName: String(localized: "Email"),
|
||||||
systemImage: "envelope.fill",
|
systemImage: "envelope.fill",
|
||||||
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
|
iconColor: Color.Text.secondary,
|
||||||
category: .contact,
|
category: .contact,
|
||||||
valueLabel: String(localized: "Email"),
|
valueLabel: String(localized: "Email"),
|
||||||
valuePlaceholder: "you@example.com",
|
valuePlaceholder: "you@example.com",
|
||||||
@ -147,7 +147,7 @@ extension ContactFieldType {
|
|||||||
id: "website",
|
id: "website",
|
||||||
displayName: String(localized: "Company Website"),
|
displayName: String(localized: "Company Website"),
|
||||||
systemImage: "globe",
|
systemImage: "globe",
|
||||||
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
|
iconColor: Color.Text.secondary,
|
||||||
category: .contact,
|
category: .contact,
|
||||||
valueLabel: String(localized: "Website URL"),
|
valueLabel: String(localized: "Website URL"),
|
||||||
valuePlaceholder: "https://company.com",
|
valuePlaceholder: "https://company.com",
|
||||||
@ -160,7 +160,7 @@ extension ContactFieldType {
|
|||||||
id: "address",
|
id: "address",
|
||||||
displayName: String(localized: "Address"),
|
displayName: String(localized: "Address"),
|
||||||
systemImage: "location.fill",
|
systemImage: "location.fill",
|
||||||
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
|
iconColor: Color.Text.secondary,
|
||||||
category: .contact,
|
category: .contact,
|
||||||
valueLabel: String(localized: "Address"),
|
valueLabel: String(localized: "Address"),
|
||||||
valuePlaceholder: "123 Main St, City, State",
|
valuePlaceholder: "123 Main St, City, State",
|
||||||
@ -187,7 +187,7 @@ extension ContactFieldType {
|
|||||||
displayName: "LinkedIn",
|
displayName: "LinkedIn",
|
||||||
systemImage: "linkedin",
|
systemImage: "linkedin",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "linkedin.com/in/username",
|
valuePlaceholder: "linkedin.com/in/username",
|
||||||
@ -201,7 +201,7 @@ extension ContactFieldType {
|
|||||||
displayName: "X",
|
displayName: "X",
|
||||||
systemImage: "x-twitter",
|
systemImage: "x-twitter",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "x.com/username",
|
valuePlaceholder: "x.com/username",
|
||||||
@ -215,7 +215,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Instagram",
|
displayName: "Instagram",
|
||||||
systemImage: "instagram",
|
systemImage: "instagram",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "instagram.com/username",
|
valuePlaceholder: "instagram.com/username",
|
||||||
@ -229,7 +229,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Facebook",
|
displayName: "Facebook",
|
||||||
systemImage: "facebook",
|
systemImage: "facebook",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "facebook.com/username",
|
valuePlaceholder: "facebook.com/username",
|
||||||
@ -243,7 +243,7 @@ extension ContactFieldType {
|
|||||||
displayName: "TikTok",
|
displayName: "TikTok",
|
||||||
systemImage: "tiktok",
|
systemImage: "tiktok",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "tiktok.com/@username",
|
valuePlaceholder: "tiktok.com/@username",
|
||||||
@ -257,7 +257,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Threads",
|
displayName: "Threads",
|
||||||
systemImage: "threads",
|
systemImage: "threads",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "threads.net/@username",
|
valuePlaceholder: "threads.net/@username",
|
||||||
@ -271,7 +271,7 @@ extension ContactFieldType {
|
|||||||
displayName: "YouTube",
|
displayName: "YouTube",
|
||||||
systemImage: "youtube",
|
systemImage: "youtube",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "youtube.com/@channel",
|
valuePlaceholder: "youtube.com/@channel",
|
||||||
@ -284,7 +284,7 @@ extension ContactFieldType {
|
|||||||
id: "snapchat",
|
id: "snapchat",
|
||||||
displayName: "Snapchat",
|
displayName: "Snapchat",
|
||||||
systemImage: "camera.fill",
|
systemImage: "camera.fill",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "snapchat.com/add/username",
|
valuePlaceholder: "snapchat.com/add/username",
|
||||||
@ -297,7 +297,7 @@ extension ContactFieldType {
|
|||||||
id: "pinterest",
|
id: "pinterest",
|
||||||
displayName: "Pinterest",
|
displayName: "Pinterest",
|
||||||
systemImage: "pin.fill",
|
systemImage: "pin.fill",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "pinterest.com/username",
|
valuePlaceholder: "pinterest.com/username",
|
||||||
@ -311,7 +311,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Twitch",
|
displayName: "Twitch",
|
||||||
systemImage: "twitch",
|
systemImage: "twitch",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "twitch.tv/username",
|
valuePlaceholder: "twitch.tv/username",
|
||||||
@ -325,7 +325,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Bluesky",
|
displayName: "Bluesky",
|
||||||
systemImage: "bluesky",
|
systemImage: "bluesky",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "bsky.app/profile/username",
|
valuePlaceholder: "bsky.app/profile/username",
|
||||||
@ -339,7 +339,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Mastodon",
|
displayName: "Mastodon",
|
||||||
systemImage: "mastodon",
|
systemImage: "mastodon",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "mastodon.social/@username",
|
valuePlaceholder: "mastodon.social/@username",
|
||||||
@ -362,7 +362,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Reddit",
|
displayName: "Reddit",
|
||||||
systemImage: "reddit",
|
systemImage: "reddit",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "reddit.com/user/username",
|
valuePlaceholder: "reddit.com/user/username",
|
||||||
@ -378,7 +378,7 @@ extension ContactFieldType {
|
|||||||
displayName: "GitHub",
|
displayName: "GitHub",
|
||||||
systemImage: "github.fill",
|
systemImage: "github.fill",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .developer,
|
category: .developer,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "github.com/username",
|
valuePlaceholder: "github.com/username",
|
||||||
@ -391,7 +391,7 @@ extension ContactFieldType {
|
|||||||
id: "gitlab",
|
id: "gitlab",
|
||||||
displayName: "GitLab",
|
displayName: "GitLab",
|
||||||
systemImage: "chevron.left.forwardslash.chevron.right",
|
systemImage: "chevron.left.forwardslash.chevron.right",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .developer,
|
category: .developer,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "gitlab.com/username",
|
valuePlaceholder: "gitlab.com/username",
|
||||||
@ -404,7 +404,7 @@ extension ContactFieldType {
|
|||||||
id: "stackoverflow",
|
id: "stackoverflow",
|
||||||
displayName: "Stack Overflow",
|
displayName: "Stack Overflow",
|
||||||
systemImage: "text.bubble.fill",
|
systemImage: "text.bubble.fill",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .developer,
|
category: .developer,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "stackoverflow.com/users/id",
|
valuePlaceholder: "stackoverflow.com/users/id",
|
||||||
@ -420,7 +420,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Telegram",
|
displayName: "Telegram",
|
||||||
systemImage: "telegram",
|
systemImage: "telegram",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "t.me/username",
|
valuePlaceholder: "t.me/username",
|
||||||
@ -433,7 +433,7 @@ extension ContactFieldType {
|
|||||||
id: "whatsapp",
|
id: "whatsapp",
|
||||||
displayName: "WhatsApp",
|
displayName: "WhatsApp",
|
||||||
systemImage: "message.fill",
|
systemImage: "message.fill",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "+1 555 123 4567",
|
valuePlaceholder: "+1 555 123 4567",
|
||||||
@ -449,7 +449,7 @@ extension ContactFieldType {
|
|||||||
id: "signal",
|
id: "signal",
|
||||||
displayName: "Signal",
|
displayName: "Signal",
|
||||||
systemImage: "bubble.left.fill",
|
systemImage: "bubble.left.fill",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "+1 555 123 4567",
|
valuePlaceholder: "+1 555 123 4567",
|
||||||
@ -466,7 +466,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Discord",
|
displayName: "Discord",
|
||||||
systemImage: "discord",
|
systemImage: "discord",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "discord.gg/invite",
|
valuePlaceholder: "discord.gg/invite",
|
||||||
@ -480,7 +480,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Slack",
|
displayName: "Slack",
|
||||||
systemImage: "slack",
|
systemImage: "slack",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "yourworkspace.slack.com",
|
valuePlaceholder: "yourworkspace.slack.com",
|
||||||
@ -494,7 +494,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Matrix",
|
displayName: "Matrix",
|
||||||
systemImage: "matrix",
|
systemImage: "matrix",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "@username:matrix.org",
|
valuePlaceholder: "@username:matrix.org",
|
||||||
@ -515,7 +515,7 @@ extension ContactFieldType {
|
|||||||
id: "venmo",
|
id: "venmo",
|
||||||
displayName: "Venmo",
|
displayName: "Venmo",
|
||||||
systemImage: "dollarsign.circle.fill",
|
systemImage: "dollarsign.circle.fill",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .payment,
|
category: .payment,
|
||||||
valueLabel: String(localized: "Username"),
|
valueLabel: String(localized: "Username"),
|
||||||
valuePlaceholder: "@username",
|
valuePlaceholder: "@username",
|
||||||
@ -528,7 +528,7 @@ extension ContactFieldType {
|
|||||||
id: "cashApp",
|
id: "cashApp",
|
||||||
displayName: "Cash App",
|
displayName: "Cash App",
|
||||||
systemImage: "dollarsign.square.fill",
|
systemImage: "dollarsign.square.fill",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .payment,
|
category: .payment,
|
||||||
valueLabel: String(localized: "Username"),
|
valueLabel: String(localized: "Username"),
|
||||||
valuePlaceholder: "$cashtag",
|
valuePlaceholder: "$cashtag",
|
||||||
@ -541,7 +541,7 @@ extension ContactFieldType {
|
|||||||
id: "paypal",
|
id: "paypal",
|
||||||
displayName: "PayPal",
|
displayName: "PayPal",
|
||||||
systemImage: "creditcard.fill",
|
systemImage: "creditcard.fill",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .payment,
|
category: .payment,
|
||||||
valueLabel: String(localized: "Email or Username"),
|
valueLabel: String(localized: "Email or Username"),
|
||||||
valuePlaceholder: "paypal.me/username",
|
valuePlaceholder: "paypal.me/username",
|
||||||
@ -554,7 +554,7 @@ extension ContactFieldType {
|
|||||||
id: "zelle",
|
id: "zelle",
|
||||||
displayName: "Zelle",
|
displayName: "Zelle",
|
||||||
systemImage: "dollarsign.arrow.circlepath",
|
systemImage: "dollarsign.arrow.circlepath",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .payment,
|
category: .payment,
|
||||||
valueLabel: String(localized: "Phone or Email"),
|
valueLabel: String(localized: "Phone or Email"),
|
||||||
valuePlaceholder: "email@example.com",
|
valuePlaceholder: "email@example.com",
|
||||||
@ -570,7 +570,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Patreon",
|
displayName: "Patreon",
|
||||||
systemImage: "patreon.fill",
|
systemImage: "patreon.fill",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .creator,
|
category: .creator,
|
||||||
valueLabel: String(localized: "Profile Link"),
|
valueLabel: String(localized: "Profile Link"),
|
||||||
valuePlaceholder: "patreon.com/username",
|
valuePlaceholder: "patreon.com/username",
|
||||||
@ -584,7 +584,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Ko-fi",
|
displayName: "Ko-fi",
|
||||||
systemImage: "ko-fi",
|
systemImage: "ko-fi",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .creator,
|
category: .creator,
|
||||||
valueLabel: String(localized: "Profile Link"),
|
valueLabel: String(localized: "Profile Link"),
|
||||||
valuePlaceholder: "ko-fi.com/username",
|
valuePlaceholder: "ko-fi.com/username",
|
||||||
@ -599,7 +599,7 @@ extension ContactFieldType {
|
|||||||
id: "calendly",
|
id: "calendly",
|
||||||
displayName: "Calendly",
|
displayName: "Calendly",
|
||||||
systemImage: "calendar",
|
systemImage: "calendar",
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color.Text.primary,
|
||||||
category: .scheduling,
|
category: .scheduling,
|
||||||
valueLabel: String(localized: "Calendly Link"),
|
valueLabel: String(localized: "Calendly Link"),
|
||||||
valuePlaceholder: "calendly.com/username",
|
valuePlaceholder: "calendly.com/username",
|
||||||
@ -614,7 +614,7 @@ extension ContactFieldType {
|
|||||||
id: "customLink",
|
id: "customLink",
|
||||||
displayName: String(localized: "Link"),
|
displayName: String(localized: "Link"),
|
||||||
systemImage: "link",
|
systemImage: "link",
|
||||||
iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
|
iconColor: Color.Text.secondary,
|
||||||
category: .other,
|
category: .other,
|
||||||
valueLabel: String(localized: "URL"),
|
valueLabel: String(localized: "URL"),
|
||||||
valuePlaceholder: "https://example.com",
|
valuePlaceholder: "https://example.com",
|
||||||
|
|||||||
@ -18,4 +18,6 @@ struct SyncableCard: Codable, Identifiable {
|
|||||||
var instagram: String
|
var instagram: String
|
||||||
/// Pre-generated QR code PNG data (CoreImage not available on watchOS).
|
/// Pre-generated QR code PNG data (CoreImage not available on watchOS).
|
||||||
var qrCodeImageData: Data?
|
var qrCodeImageData: Data?
|
||||||
|
/// Pre-generated App Clip URL QR code PNG data.
|
||||||
|
var appClipQRCodeImageData: Data?
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,16 +3,6 @@
|
|||||||
"strings" : {
|
"strings" : {
|
||||||
"(%@)" : {
|
"(%@)" : {
|
||||||
|
|
||||||
},
|
|
||||||
"%@, %@" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "new",
|
|
||||||
"value" : "%1$@, %2$@"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"%@, %@, %@" : {
|
"%@, %@, %@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -55,6 +45,10 @@
|
|||||||
},
|
},
|
||||||
"Accreditations" : {
|
"Accreditations" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Add" : {
|
||||||
|
"comment" : "A button label that says \"Add\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Add %@" : {
|
"Add %@" : {
|
||||||
|
|
||||||
@ -84,12 +78,6 @@
|
|||||||
},
|
},
|
||||||
"Add Contact Fields" : {
|
"Add Contact Fields" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Add note" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"Add tag" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Address" : {
|
"Address" : {
|
||||||
|
|
||||||
@ -176,6 +164,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Checking..." : {
|
||||||
|
"comment" : "Placeholder text while waiting to check the sync status.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Choose a card in the My Cards tab to start sharing." : {
|
"Choose a card in the My Cards tab to start sharing." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -420,6 +412,14 @@
|
|||||||
},
|
},
|
||||||
"Home" : {
|
"Home" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"iCloud Sync" : {
|
||||||
|
"comment" : "A label displayed above the iCloud sync section in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"iCloud sync status" : {
|
||||||
|
"comment" : "An accessibility hint describing the purpose of the \"iCloud sync status\" label.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Images & layout" : {
|
"Images & layout" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -454,6 +454,10 @@
|
|||||||
},
|
},
|
||||||
"Links" : {
|
"Links" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Local only" : {
|
||||||
|
"comment" : "Status text indicating that the app is using only local storage (CloudKit sync is disabled).",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Logo" : {
|
"Logo" : {
|
||||||
|
|
||||||
@ -466,9 +470,6 @@
|
|||||||
},
|
},
|
||||||
"Middle Name" : {
|
"Middle Name" : {
|
||||||
|
|
||||||
},
|
|
||||||
"More..." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Next step: create your first card. Once it is saved, you can start sharing immediately." : {
|
"Next step: create your first card. Once it is saved, you can start sharing immediately." : {
|
||||||
"comment" : "A description of the next step in the onboarding process, where a user can create their first card.",
|
"comment" : "A description of the next step in the onboarding process, where a user can create their first card.",
|
||||||
@ -479,6 +480,9 @@
|
|||||||
},
|
},
|
||||||
"No contacts yet" : {
|
"No contacts yet" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No notes yet" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Notes" : {
|
"Notes" : {
|
||||||
|
|
||||||
@ -663,6 +667,13 @@
|
|||||||
},
|
},
|
||||||
"Selected" : {
|
"Selected" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Separate tags with commas" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Share" : {
|
||||||
|
"comment" : "A button label that triggers a share action.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Share card" : {
|
"Share card" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -736,9 +747,6 @@
|
|||||||
"Share your card quickly from your home screen and your watch face." : {
|
"Share your card quickly from your home screen and your watch face." : {
|
||||||
"comment" : "A description of how users can share their business cards on their iPhone and watch faces.",
|
"comment" : "A description of how users can share their business cards on their iPhone and watch faces.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Shared With" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"ShareEmailBodySimple" : {
|
"ShareEmailBodySimple" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -833,12 +841,31 @@
|
|||||||
},
|
},
|
||||||
"Support & Funding" : {
|
"Support & Funding" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Synced" : {
|
||||||
|
"comment" : "Text describing a successful sync with iCloud.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Synced %@" : {
|
||||||
|
"comment" : "Text that appears in the status bar or other user-facing interface to indicate that data is synced with the cloud. The argument is a relative time description (e.g. \"2 days ago\").",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Syncing..." : {
|
||||||
|
"comment" : "Status text indicating that the app is currently syncing with iCloud.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Tags" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tap + to add a contact, scan a QR code, or track who you share your card with." : {
|
"Tap + to add a contact, scan a QR code, or track who you share your card with." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tap a field below to add it" : {
|
"Tap a field below to add it" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Tap Add to save context for this contact." : {
|
||||||
|
"comment" : "A description below the \"Add\" button in the \"Notes\" section of a contact detail card, explaining that tapping the button will allow the user to add a note to the contact.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Tap to choose how images appear in the card header" : {
|
"Tap to choose how images appear in the card header" : {
|
||||||
|
|
||||||
|
|||||||
156
BusinessCard/Services/CloudKitSyncMonitor.swift
Normal file
156
BusinessCard/Services/CloudKitSyncMonitor.swift
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Observation
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// Tracks CloudKit sync status for SwiftData-backed iCloud sync.
|
||||||
|
/// Observes NSPersistentCloudKitContainer events to show sync state to the user.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class CloudKitSyncMonitor {
|
||||||
|
/// Current sync state for UI display.
|
||||||
|
enum SyncState: Equatable {
|
||||||
|
case unknown
|
||||||
|
case syncing
|
||||||
|
case synced(lastExportDate: Date?)
|
||||||
|
case error(String)
|
||||||
|
case disabled // CloudKit sync not enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: SyncState = .unknown
|
||||||
|
var lastExportDate: Date?
|
||||||
|
var lastError: String?
|
||||||
|
|
||||||
|
private nonisolated(unsafe) var observer: NSObjectProtocol?
|
||||||
|
|
||||||
|
init(isCloudKitEnabled: Bool) {
|
||||||
|
if !isCloudKitEnabled {
|
||||||
|
state = .disabled
|
||||||
|
Design.debugLog("CloudKitSyncMonitor: CloudKit sync disabled (local-only storage)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Design.debugLog("CloudKitSyncMonitor: Observing NSPersistentCloudKitContainer events")
|
||||||
|
observer = NotificationCenter.default.addObserver(
|
||||||
|
forName: NSPersistentCloudKitContainer.eventChangedNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
let info = Self.extractEventInfo(from: notification)
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleEventInfo(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state - assume syncing until we get first event
|
||||||
|
state = .syncing
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let observer {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EventInfo: Sendable {
|
||||||
|
let typeRaw: Int
|
||||||
|
let endDate: Date?
|
||||||
|
let startDate: Date
|
||||||
|
let errorMessage: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static nonisolated func extractEventInfo(from notification: Notification) -> EventInfo? {
|
||||||
|
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return EventInfo(
|
||||||
|
typeRaw: event.type.rawValue,
|
||||||
|
endDate: event.endDate,
|
||||||
|
startDate: event.startDate,
|
||||||
|
errorMessage: event.error?.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEventInfo(_ info: EventInfo?) {
|
||||||
|
guard let info else { return }
|
||||||
|
|
||||||
|
if let errorMsg = info.errorMessage {
|
||||||
|
lastError = errorMsg
|
||||||
|
state = .error(errorMsg)
|
||||||
|
Design.debugLog("CloudKitSyncMonitor: Sync FAILED - type \(info.typeRaw) error: \(errorMsg)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = nil
|
||||||
|
|
||||||
|
switch info.typeRaw {
|
||||||
|
case 0: // setup
|
||||||
|
state = .syncing
|
||||||
|
Design.debugLog("CloudKitSyncMonitor: Setup in progress")
|
||||||
|
case 1: // import
|
||||||
|
state = .synced(lastExportDate: lastExportDate)
|
||||||
|
let duration = eventDuration(start: info.startDate, end: info.endDate)
|
||||||
|
Design.debugLog("CloudKitSyncMonitor: Import SUCCESS\(duration)")
|
||||||
|
case 2: // export
|
||||||
|
lastExportDate = info.endDate ?? Date()
|
||||||
|
state = .synced(lastExportDate: lastExportDate)
|
||||||
|
let duration = eventDuration(start: info.startDate, end: info.endDate)
|
||||||
|
Design.debugLog("CloudKitSyncMonitor: Export SUCCESS\(duration)")
|
||||||
|
default:
|
||||||
|
state = .synced(lastExportDate: lastExportDate)
|
||||||
|
Design.debugLog("CloudKitSyncMonitor: Event type \(info.typeRaw) completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eventDuration(start: Date, end: Date?) -> String {
|
||||||
|
guard let end else { return "" }
|
||||||
|
let ms = end.timeIntervalSince(start) * 1000
|
||||||
|
return " (\(String(format: "%.0f", ms))ms)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-friendly status text for Settings and indicators.
|
||||||
|
var statusText: String {
|
||||||
|
switch state {
|
||||||
|
case .unknown:
|
||||||
|
return String(localized: "Checking...")
|
||||||
|
case .syncing:
|
||||||
|
return String(localized: "Syncing...")
|
||||||
|
case .synced(let date):
|
||||||
|
if let date {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
let relative = formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
return String(localized: "Synced \(relative)")
|
||||||
|
}
|
||||||
|
return String(localized: "Synced")
|
||||||
|
case .error(let message):
|
||||||
|
return message
|
||||||
|
case .disabled:
|
||||||
|
return String(localized: "Local only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SF Symbol for current state.
|
||||||
|
var statusIcon: String {
|
||||||
|
switch state {
|
||||||
|
case .unknown, .syncing:
|
||||||
|
return "arrow.triangle.2.circlepath"
|
||||||
|
case .synced:
|
||||||
|
return "checkmark.icloud"
|
||||||
|
case .error:
|
||||||
|
return "exclamationmark.icloud"
|
||||||
|
case .disabled:
|
||||||
|
return "internaldrive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether sync appears healthy (synced or syncing).
|
||||||
|
var isHealthy: Bool {
|
||||||
|
switch state {
|
||||||
|
case .synced, .syncing:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
extension UIImage {
|
extension UIImage {
|
||||||
/// Extracts dominant colors from the image using pixel sampling.
|
/// Extracts dominant colors from the image using pixel sampling.
|
||||||
/// - Parameter count: Number of colors to extract (default 3)
|
/// - Parameter count: Number of colors to extract (default 3)
|
||||||
/// - Returns: Array of SwiftUI Colors, always includes white and black as fallbacks
|
/// - Returns: Array of SwiftUI Colors, always includes app theme fallback colors
|
||||||
func dominantColors(count: Int = 3) -> [Color] {
|
func dominantColors(count: Int = 3) -> [Color] {
|
||||||
guard let cgImage = self.cgImage else {
|
guard let cgImage = self.cgImage else {
|
||||||
return defaultColors(count: count)
|
return defaultColors(count: count)
|
||||||
@ -18,14 +19,14 @@ extension UIImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a smaller sample size for performance
|
// Create a smaller sample size for performance
|
||||||
let sampleSize = 50
|
let sampleSize = Design.ColorExtraction.sampleSize
|
||||||
|
|
||||||
guard let context = CGContext(
|
guard let context = CGContext(
|
||||||
data: nil,
|
data: nil,
|
||||||
width: sampleSize,
|
width: sampleSize,
|
||||||
height: sampleSize,
|
height: sampleSize,
|
||||||
bitsPerComponent: 8,
|
bitsPerComponent: 8,
|
||||||
bytesPerRow: sampleSize * 4,
|
bytesPerRow: sampleSize * Design.ColorExtraction.rgbaBytesPerPixel,
|
||||||
space: CGColorSpaceCreateDeviceRGB(),
|
space: CGColorSpaceCreateDeviceRGB(),
|
||||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
) else {
|
) else {
|
||||||
@ -38,7 +39,10 @@ extension UIImage {
|
|||||||
return defaultColors(count: count)
|
return defaultColors(count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = pixelData.bindMemory(to: UInt8.self, capacity: sampleSize * sampleSize * 4)
|
let data = pixelData.bindMemory(
|
||||||
|
to: UInt8.self,
|
||||||
|
capacity: sampleSize * sampleSize * Design.ColorExtraction.rgbaBytesPerPixel
|
||||||
|
)
|
||||||
|
|
||||||
// Collect colors with their frequencies
|
// Collect colors with their frequencies
|
||||||
var colorCounts: [ColorBucket: Int] = [:]
|
var colorCounts: [ColorBucket: Int] = [:]
|
||||||
@ -52,17 +56,18 @@ extension UIImage {
|
|||||||
let a = data[offset + 3]
|
let a = data[offset + 3]
|
||||||
|
|
||||||
// Skip transparent or near-transparent pixels
|
// Skip transparent or near-transparent pixels
|
||||||
guard a > 128 else { continue }
|
guard a > Design.ColorExtraction.alphaVisibilityThreshold else { continue }
|
||||||
|
|
||||||
// Skip near-white and near-black (we'll add those as defaults)
|
// Skip near-white and near-black (we'll add those as defaults)
|
||||||
let brightness = (Int(r) + Int(g) + Int(b)) / 3
|
let brightness = (Int(r) + Int(g) + Int(b)) / 3
|
||||||
guard brightness > 30 && brightness < 225 else { continue }
|
guard brightness > Design.ColorExtraction.minBrightness
|
||||||
|
&& brightness < Design.ColorExtraction.maxBrightness else { continue }
|
||||||
|
|
||||||
// Bucket colors to reduce noise (group similar colors)
|
// Bucket colors to reduce noise (group similar colors)
|
||||||
let bucket = ColorBucket(
|
let bucket = ColorBucket(
|
||||||
r: UInt8((Int(r) / 32) * 32),
|
r: UInt8((Int(r) / Design.ColorExtraction.bucketQuantizationStep) * Design.ColorExtraction.bucketQuantizationStep),
|
||||||
g: UInt8((Int(g) / 32) * 32),
|
g: UInt8((Int(g) / Design.ColorExtraction.bucketQuantizationStep) * Design.ColorExtraction.bucketQuantizationStep),
|
||||||
b: UInt8((Int(b) / 32) * 32)
|
b: UInt8((Int(b) / Design.ColorExtraction.bucketQuantizationStep) * Design.ColorExtraction.bucketQuantizationStep)
|
||||||
)
|
)
|
||||||
|
|
||||||
colorCounts[bucket, default: 0] += 1
|
colorCounts[bucket, default: 0] += 1
|
||||||
@ -77,9 +82,9 @@ extension UIImage {
|
|||||||
// Add top colors, ensuring they're distinct
|
// Add top colors, ensuring they're distinct
|
||||||
for (bucket, _) in sortedColors {
|
for (bucket, _) in sortedColors {
|
||||||
let color = Color(
|
let color = Color(
|
||||||
red: Double(bucket.r) / 255.0,
|
red: Double(bucket.r) / Design.ColorExtraction.rgbDenominator,
|
||||||
green: Double(bucket.g) / 255.0,
|
green: Double(bucket.g) / Design.ColorExtraction.rgbDenominator,
|
||||||
blue: Double(bucket.b) / 255.0
|
blue: Double(bucket.b) / Design.ColorExtraction.rgbDenominator
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check if this color is distinct enough from existing ones
|
// Check if this color is distinct enough from existing ones
|
||||||
@ -92,20 +97,20 @@ extension UIImage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always include white as first option
|
// Always include high-contrast app fallback colors first.
|
||||||
var result: [Color] = [.white]
|
var result: [Color] = [Color.AppText.inverted]
|
||||||
result.append(contentsOf: extractedColors)
|
result.append(contentsOf: extractedColors)
|
||||||
|
|
||||||
// Add black if we have room
|
// Add primary text color if we have room.
|
||||||
if result.count < count {
|
if result.count < count {
|
||||||
result.append(.black)
|
result.append(Color.Text.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array(result.prefix(count))
|
return Array(result.prefix(count))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func defaultColors(count: Int) -> [Color] {
|
private func defaultColors(count: Int) -> [Color] {
|
||||||
let defaults: [Color] = [.white, .black, Color(red: 0.2, green: 0.2, blue: 0.2)]
|
let defaults: [Color] = [Color.AppText.inverted, Color.Text.primary, Color.Text.secondary]
|
||||||
return Array(defaults.prefix(count))
|
return Array(defaults.prefix(count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,7 +127,7 @@ private struct ColorBucket: Hashable {
|
|||||||
|
|
||||||
private extension Color {
|
private extension Color {
|
||||||
/// Checks if two colors are visually similar.
|
/// Checks if two colors are visually similar.
|
||||||
func isClose(to other: Color, threshold: Double = 0.15) -> Bool {
|
func isClose(to other: Color, threshold: Double = Design.ColorExtraction.colorSimilarityThreshold) -> Bool {
|
||||||
guard let selfComponents = self.cgColor?.components,
|
guard let selfComponents = self.cgColor?.components,
|
||||||
let otherComponents = other.cgColor?.components,
|
let otherComponents = other.cgColor?.components,
|
||||||
selfComponents.count >= 3,
|
selfComponents.count >= 3,
|
||||||
@ -137,4 +142,3 @@ private extension Color {
|
|||||||
return rDiff < threshold && gDiff < threshold && bDiff < threshold
|
return rDiff < threshold && gDiff < threshold && bDiff < threshold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
BusinessCard/Services/SharedCardRecordNameCache.swift
Normal file
26
BusinessCard/Services/SharedCardRecordNameCache.swift
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Caches CloudKit record names for shared cards to avoid re-uploading when syncing to Watch.
|
||||||
|
/// Populated when user shares via App Clip; used when syncing cards to Watch for App Clip QR generation.
|
||||||
|
enum SharedCardRecordNameCache {
|
||||||
|
private static let defaults = UserDefaults.standard
|
||||||
|
private static let keyPrefix = "SharedCardRecordName."
|
||||||
|
private static let expiryKeyPrefix = "SharedCardRecordNameExpiry."
|
||||||
|
|
||||||
|
/// Stores a record name for a card. Call when user shares via App Clip.
|
||||||
|
static func set(recordName: String, cardID: UUID, expiresAt: Date) {
|
||||||
|
defaults.set(recordName, forKey: keyPrefix + cardID.uuidString)
|
||||||
|
defaults.set(expiresAt.timeIntervalSince1970, forKey: expiryKeyPrefix + cardID.uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a valid (non-expired) record name for the card, or nil if missing/expired.
|
||||||
|
static func recordName(for cardID: UUID) -> String? {
|
||||||
|
guard let name = defaults.string(forKey: keyPrefix + cardID.uuidString),
|
||||||
|
let expiryInterval = defaults.object(forKey: expiryKeyPrefix + cardID.uuidString) as? TimeInterval else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let expiresAt = Date(timeIntervalSince1970: expiryInterval)
|
||||||
|
guard Date() < expiresAt else { return nil }
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,22 +40,31 @@ final class WatchConnectivityService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// In DEBUG, isWatchAppInstalled may be false even when running from Xcode
|
// Always send when paired. applicationContext persists and is delivered when Watch
|
||||||
// So we only require isPaired and isReachable or just try to send anyway
|
// launches (even if installed later). isWatchAppInstalled can lag after fresh install.
|
||||||
#if DEBUG
|
|
||||||
guard session.isPaired else {
|
guard session.isPaired else {
|
||||||
Design.debugLog("WatchConnectivity: Watch not paired")
|
Design.debugLog("WatchConnectivity: Watch not paired")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Design.debugLog("WatchConnectivity: DEBUG mode - paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
|
Design.debugLog("WatchConnectivity: paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
|
||||||
#else
|
|
||||||
guard session.isPaired, session.isWatchAppInstalled else {
|
|
||||||
Design.debugLog("WatchConnectivity: Watch not available (paired: \(session.isPaired), installed: \(session.isWatchAppInstalled))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
sendCardsToWatch(cards)
|
Task {
|
||||||
|
await ensureAppClipRecordNameForDefaultCard(cards)
|
||||||
|
sendCardsToWatch(cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads default card to CloudKit if cache is empty, so Watch can show App Clip QR.
|
||||||
|
private func ensureAppClipRecordNameForDefaultCard(_ cards: [BusinessCard]) async {
|
||||||
|
guard let defaultCard = cards.first(where: { $0.isDefault }) ?? cards.first,
|
||||||
|
SharedCardRecordNameCache.recordName(for: defaultCard.id) == nil else { return }
|
||||||
|
do {
|
||||||
|
let result = try await SharedCardCloudKitService().uploadSharedCard(defaultCard)
|
||||||
|
SharedCardRecordNameCache.set(recordName: result.recordName, cardID: defaultCard.id, expiresAt: result.expiresAt)
|
||||||
|
Design.debugLog("WatchConnectivity: Cached App Clip recordName for Watch")
|
||||||
|
} catch {
|
||||||
|
Design.debugLog("WatchConnectivity: Failed to upload for App Clip QR: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendCardsToWatch(_ cards: [BusinessCard]) {
|
private func sendCardsToWatch(_ cards: [BusinessCard]) {
|
||||||
@ -149,6 +158,13 @@ final class WatchConnectivityService: NSObject {
|
|||||||
// Generate QR code image data on iOS (CoreImage not available on watchOS)
|
// Generate QR code image data on iOS (CoreImage not available on watchOS)
|
||||||
let qrImageData = generateQRCodePNGData(from: vCardPayload)
|
let qrImageData = generateQRCodePNGData(from: vCardPayload)
|
||||||
|
|
||||||
|
// App Clip QR: only when we have a cached recordName (from when user shared via App Clip)
|
||||||
|
let appClipQRImageData: Data? = {
|
||||||
|
guard let recordName = SharedCardRecordNameCache.recordName(for: card.id),
|
||||||
|
let url = AppIdentifiers.appClipURL(recordName: recordName) else { return nil }
|
||||||
|
return generateQRCodePNGData(from: url.absoluteString)
|
||||||
|
}()
|
||||||
|
|
||||||
// Use fullName - the single source of truth for display names
|
// Use fullName - the single source of truth for display names
|
||||||
let syncDisplayName = card.fullName
|
let syncDisplayName = card.fullName
|
||||||
Design.debugLog("WatchConnectivity: Syncing card '\(syncDisplayName)'")
|
Design.debugLog("WatchConnectivity: Syncing card '\(syncDisplayName)'")
|
||||||
@ -168,7 +184,8 @@ final class WatchConnectivityService: NSObject {
|
|||||||
linkedIn: linkedIn,
|
linkedIn: linkedIn,
|
||||||
twitter: twitter,
|
twitter: twitter,
|
||||||
instagram: instagram,
|
instagram: instagram,
|
||||||
qrCodeImageData: qrImageData
|
qrCodeImageData: qrImageData,
|
||||||
|
appClipQRCodeImageData: appClipQRImageData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,9 @@ final class AppClipShareState {
|
|||||||
defer { isUploading = false }
|
defer { isUploading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
uploadResult = try await service.uploadSharedCard(card)
|
let result = try await service.uploadSharedCard(card)
|
||||||
|
uploadResult = result
|
||||||
|
SharedCardRecordNameCache.set(recordName: result.recordName, cardID: card.id, expiresAt: result.expiresAt)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ final class AppState {
|
|||||||
let preferences: AppPreferencesStore
|
let preferences: AppPreferencesStore
|
||||||
let appSettings: AppSettingsStore
|
let appSettings: AppSettingsStore
|
||||||
let shareLinkService: ShareLinkProviding
|
let shareLinkService: ShareLinkProviding
|
||||||
|
let cloudKitSyncMonitor: CloudKitSyncMonitor
|
||||||
|
|
||||||
var preferredColorScheme: ColorScheme? {
|
var preferredColorScheme: ColorScheme? {
|
||||||
preferences.preferredColorScheme
|
preferences.preferredColorScheme
|
||||||
@ -29,6 +30,7 @@ final class AppState {
|
|||||||
self.preferences = AppPreferencesStore()
|
self.preferences = AppPreferencesStore()
|
||||||
self.appSettings = AppSettingsStore(modelContext: modelContext)
|
self.appSettings = AppSettingsStore(modelContext: modelContext)
|
||||||
self.shareLinkService = ShareLinkService()
|
self.shareLinkService = ShareLinkService()
|
||||||
|
self.cloudKitSyncMonitor = CloudKitSyncMonitor(isCloudKitEnabled: AppIdentifiers.isCloudKitSyncEnabled)
|
||||||
|
|
||||||
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@ -115,6 +115,48 @@ final class ContactsStore: ContactTracking {
|
|||||||
fetchContacts()
|
fetchContacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates an existing contact and replaces its contact fields.
|
||||||
|
func updateContact(
|
||||||
|
_ contact: Contact,
|
||||||
|
name: String,
|
||||||
|
role: String = "",
|
||||||
|
company: String = "",
|
||||||
|
notes: String = "",
|
||||||
|
tags: String = "",
|
||||||
|
followUpDate: Date? = nil,
|
||||||
|
contactFields: [ContactField] = [],
|
||||||
|
photoData: Data? = nil
|
||||||
|
) {
|
||||||
|
contact.name = name
|
||||||
|
contact.role = role
|
||||||
|
contact.company = company
|
||||||
|
contact.notes = notes
|
||||||
|
contact.tags = tags
|
||||||
|
contact.followUpDate = followUpDate
|
||||||
|
contact.photoData = photoData
|
||||||
|
|
||||||
|
if let existingFields = contact.contactFields {
|
||||||
|
for field in existingFields {
|
||||||
|
modelContext.delete(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fieldsToAttach: [ContactField] = []
|
||||||
|
for (index, field) in contactFields.enumerated() {
|
||||||
|
field.orderIndex = index
|
||||||
|
field.contact = contact
|
||||||
|
modelContext.insert(field)
|
||||||
|
fieldsToAttach.append(field)
|
||||||
|
}
|
||||||
|
contact.contactFields = fieldsToAttach
|
||||||
|
|
||||||
|
contact.phone = fieldsToAttach.first(where: { $0.typeId == "phone" })?.value ?? ""
|
||||||
|
contact.email = fieldsToAttach.first(where: { $0.typeId == "email" })?.value ?? ""
|
||||||
|
|
||||||
|
saveContext()
|
||||||
|
fetchContacts()
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates a contact's notes
|
/// Updates a contact's notes
|
||||||
func updateNotes(for contact: Contact, notes: String) {
|
func updateNotes(for contact: Contact, notes: String) {
|
||||||
contact.notes = notes
|
contact.notes = notes
|
||||||
|
|||||||
@ -9,7 +9,7 @@ struct FloatingShareButton: View {
|
|||||||
Image(systemName: "qrcode")
|
Image(systemName: "qrcode")
|
||||||
.typography(.title2)
|
.typography(.title2)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
.frame(width: Design.CardSize.floatingButtonSize, height: Design.CardSize.floatingButtonSize)
|
.frame(width: Design.CardSize.floatingButtonSize, height: Design.CardSize.floatingButtonSize)
|
||||||
.background(
|
.background(
|
||||||
Circle()
|
Circle()
|
||||||
|
|||||||
@ -54,6 +54,8 @@ struct RootTabView: View {
|
|||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
updateOnboardingPresentation()
|
updateOnboardingPresentation()
|
||||||
|
// Re-sync cards to Watch when app becomes active (covers fresh Watch install)
|
||||||
|
WatchConnectivityService.shared.syncCards(appState.cardStore.cards)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,8 @@ struct CardsHomeView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(String.localized("My Cards"))
|
.navigationTitle(String.localized("My Cards"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
||||||
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button(String.localized("Add Card"), systemImage: "plus") {
|
Button(String.localized("Add Card"), systemImage: "plus") {
|
||||||
@ -40,6 +42,12 @@ struct CardsHomeView: View {
|
|||||||
.accessibilityHint(String.localized("Create a new business card"))
|
.accessibilityHint(String.localized("Create a new business card"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if appState.cloudKitSyncMonitor.state != .disabled {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
SyncStatusBadge(monitor: appState.cloudKitSyncMonitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cardStore.cards.count > 1 && cardStore.selectedCard != nil {
|
if cardStore.cards.count > 1 && cardStore.selectedCard != nil {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button(String.localized("Delete"), systemImage: "trash") {
|
Button(String.localized("Delete"), systemImage: "trash") {
|
||||||
@ -96,6 +104,29 @@ struct CardsHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status Badge
|
||||||
|
|
||||||
|
private struct SyncStatusBadge: View {
|
||||||
|
let monitor: CloudKitSyncMonitor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: monitor.statusIcon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(iconColor)
|
||||||
|
.symbolEffect(.variableColor.iterative.reversing, isActive: monitor.state == .syncing)
|
||||||
|
.accessibilityLabel(monitor.statusText)
|
||||||
|
.accessibilityHint(String(localized: "iCloud sync status"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconColor: Color {
|
||||||
|
switch monitor.state {
|
||||||
|
case .synced: return AppStatus.success
|
||||||
|
case .error: return AppStatus.error
|
||||||
|
default: return Color.AppText.secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self)
|
let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self)
|
||||||
return CardsHomeView()
|
return CardsHomeView()
|
||||||
|
|||||||
@ -573,7 +573,7 @@ private struct ContactFieldRowView: View {
|
|||||||
.background(Color.AppBackground.base.opacity(0.82))
|
.background(Color.AppBackground.base.opacity(0.82))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
.stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||||
)
|
)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
|
|||||||
@ -410,8 +410,8 @@ private struct CustomColorSwatch: View {
|
|||||||
if customColor == nil {
|
if customColor == nil {
|
||||||
Image(systemName: "eyedropper")
|
Image(systemName: "eyedropper")
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusSmall)
|
.shadow(color: Color.AppBackground.base.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusSmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
|
.frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
|
||||||
@ -945,7 +945,7 @@ private struct ContactFieldRowView: View {
|
|||||||
.overlay(
|
.overlay(
|
||||||
field.fieldType.iconImage()
|
field.fieldType.iconImage()
|
||||||
.typography(.title3)
|
.typography(.title3)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
@ -1064,8 +1064,8 @@ private struct PreviewCardButton: View {
|
|||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
.stroke(
|
.stroke(
|
||||||
colorScheme == .dark
|
colorScheme == .dark
|
||||||
? Color.white.opacity(Design.Opacity.subtle)
|
? Color.AppText.inverted.opacity(Design.Opacity.subtle)
|
||||||
: Color.black.opacity(Design.Opacity.light),
|
: Color.Text.primary.opacity(Design.Opacity.light),
|
||||||
lineWidth: Design.LineWidth.thin
|
lineWidth: Design.LineWidth.thin
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ struct ContactFieldEditorSheet: View {
|
|||||||
fieldType: ContactFieldType,
|
fieldType: ContactFieldType,
|
||||||
initialValue: String = "",
|
initialValue: String = "",
|
||||||
initialTitle: String = "",
|
initialTitle: String = "",
|
||||||
themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2),
|
themeColor: Color = Color.Text.secondary,
|
||||||
onSave: @escaping (String, String) -> Void,
|
onSave: @escaping (String, String) -> Void,
|
||||||
onDelete: (() -> Void)? = nil
|
onDelete: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ struct CropGridLines: View {
|
|||||||
HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) {
|
HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) {
|
||||||
ForEach(0..<2, id: \.self) { _ in
|
ForEach(0..<2, id: \.self) { _ in
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.white.opacity(Design.Opacity.light))
|
.fill(.white.opacity(Design.Opacity.light))
|
||||||
.frame(width: Design.LineWidth.thin, height: cropSize.height)
|
.frame(width: Design.LineWidth.thin, height: cropSize.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -17,7 +17,7 @@ struct CropGridLines: View {
|
|||||||
VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) {
|
VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) {
|
||||||
ForEach(0..<2, id: \.self) { _ in
|
ForEach(0..<2, id: \.self) { _ in
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.white.opacity(Design.Opacity.light))
|
.fill(.white.opacity(Design.Opacity.light))
|
||||||
.frame(width: cropSize.width, height: Design.LineWidth.thin)
|
.frame(width: cropSize.width, height: Design.LineWidth.thin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ struct CropOverlay: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.black.opacity(Design.Opacity.accent))
|
.fill(Color.black.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.clear)
|
.fill(Color.clear)
|
||||||
@ -19,7 +19,7 @@ struct CropOverlay: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.stroke(Color.white, lineWidth: Design.LineWidth.thin)
|
.stroke(.white, lineWidth: Design.LineWidth.thin)
|
||||||
.frame(width: cropSize.width, height: cropSize.height)
|
.frame(width: cropSize.width, height: cropSize.height)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ struct FieldHeaderView: View {
|
|||||||
.overlay(
|
.overlay(
|
||||||
fieldType.iconImage()
|
fieldType.iconImage()
|
||||||
.typography(.title3)
|
.typography(.title3)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(fieldType.displayName)
|
Text(fieldType.displayName)
|
||||||
|
|||||||
@ -88,7 +88,7 @@ struct PhotoCropperSheet: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
// Dark background
|
// Always black background (consistent regardless of system theme)
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
// Image with gestures
|
// Image with gestures
|
||||||
@ -120,7 +120,8 @@ struct PhotoCropperSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(Color.black.opacity(Design.Opacity.heavy), for: .navigationBar)
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(String.localized("Cancel")) {
|
Button(String.localized("Cancel")) {
|
||||||
|
|||||||
@ -5,6 +5,23 @@ struct ContactRowView: View {
|
|||||||
let contact: Contact
|
let contact: Contact
|
||||||
let relativeDate: String
|
let relativeDate: String
|
||||||
|
|
||||||
|
private var sourceBadgeText: String {
|
||||||
|
if contact.isReceivedCard {
|
||||||
|
return String.localized("Received")
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = contact.cardLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.localizedCaseInsensitiveCompare("Manual") == .orderedSame {
|
||||||
|
return String.localized("Added")
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.isEmpty ? String.localized("Shared") : String.localized(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chipBackground: Color { Color.AppBackground.accent }
|
||||||
|
private var chipTextColor: Color { Color.Text.primary }
|
||||||
|
private var chipStroke: Color { Color.Text.tertiary.opacity(Design.Opacity.light) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
ContactAvatarView(contact: contact)
|
ContactAvatarView(contact: contact)
|
||||||
@ -14,10 +31,11 @@ struct ContactRowView: View {
|
|||||||
Text(contact.name)
|
Text(contact.name)
|
||||||
.typography(.heading)
|
.typography(.heading)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
if contact.isReceivedCard {
|
if contact.isReceivedCard {
|
||||||
Image(systemName: "arrow.down.circle.fill")
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(Color.Accent.mint)
|
.foregroundStyle(Color.Accent.mint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,9 +58,14 @@ struct ContactRowView: View {
|
|||||||
ForEach(contact.tagList.prefix(2), id: \.self) { tag in
|
ForEach(contact.tagList.prefix(2), id: \.self) { tag in
|
||||||
Text(tag)
|
Text(tag)
|
||||||
.typography(.caption2)
|
.typography(.caption2)
|
||||||
|
.foregroundStyle(chipTextColor)
|
||||||
.padding(.horizontal, Design.Spacing.xSmall)
|
.padding(.horizontal, Design.Spacing.xSmall)
|
||||||
.padding(.vertical, Design.Spacing.xxSmall)
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
.background(Color.AppBackground.accent)
|
.background(chipBackground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
||||||
|
.stroke(chipStroke, lineWidth: Design.LineWidth.thin)
|
||||||
|
)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,16 +78,14 @@ struct ContactRowView: View {
|
|||||||
Text(relativeDate)
|
Text(relativeDate)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(Color.Text.secondary)
|
.foregroundStyle(Color.Text.secondary)
|
||||||
Text(String.localized(contact.cardLabel))
|
Text(sourceBadgeText)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.padding(.horizontal, Design.Spacing.small)
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
.padding(.vertical, Design.Spacing.xxSmall)
|
|
||||||
.background(Color.AppBackground.base)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(contact.name)
|
.accessibilityLabel(contact.name)
|
||||||
.accessibilityValue("\(contact.role), \(contact.company)")
|
.accessibilityValue("\(contact.role), \(contact.company), \(sourceBadgeText)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,63 +4,118 @@ import Bedrock
|
|||||||
struct ContactsListView: View {
|
struct ContactsListView: View {
|
||||||
@Bindable var contactsStore: ContactsStore
|
@Bindable var contactsStore: ContactsStore
|
||||||
|
|
||||||
var body: some View {
|
private var sortedContacts: [Contact] {
|
||||||
List {
|
contactsStore.visibleContacts.sorted { lhs, rhs in
|
||||||
let overdueContacts = contactsStore.visibleContacts.filter { $0.isFollowUpOverdue }
|
let left = sortKey(for: lhs.name)
|
||||||
if !overdueContacts.isEmpty {
|
let right = sortKey(for: rhs.name)
|
||||||
Section {
|
|
||||||
ForEach(overdueContacts) { contact in
|
|
||||||
NavigationLink(value: contact) {
|
|
||||||
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Label(String.localized("Follow-up Overdue"), systemImage: "exclamationmark.circle")
|
|
||||||
.foregroundStyle(Color.Accent.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let receivedCards = contactsStore.visibleContacts.filter { $0.isReceivedCard && !$0.isFollowUpOverdue }
|
if left.isEmpty && right.isEmpty {
|
||||||
if !receivedCards.isEmpty {
|
return lhs.lastSharedDate > rhs.lastSharedDate
|
||||||
Section {
|
|
||||||
ForEach(receivedCards) { contact in
|
|
||||||
NavigationLink(value: contact) {
|
|
||||||
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete { indexSet in
|
|
||||||
for index in indexSet {
|
|
||||||
contactsStore.deleteContact(receivedCards[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Label(String.localized("Received Cards"), systemImage: "tray.and.arrow.down")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if left.isEmpty { return false }
|
||||||
let sharedContacts = contactsStore.visibleContacts.filter { !$0.isReceivedCard && !$0.isFollowUpOverdue }
|
if right.isEmpty { return true }
|
||||||
if !sharedContacts.isEmpty {
|
return left.localizedCaseInsensitiveCompare(right) == .orderedAscending
|
||||||
Section {
|
|
||||||
ForEach(sharedContacts) { contact in
|
|
||||||
NavigationLink(value: contact) {
|
|
||||||
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete { indexSet in
|
|
||||||
for index in indexSet {
|
|
||||||
contactsStore.deleteContact(sharedContacts[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Shared With")
|
|
||||||
.typography(.heading)
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.navigationDestination(for: Contact.self) { contact in
|
|
||||||
ContactDetailView(contact: contact)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var contactsBySection: [String: [Contact]] {
|
||||||
|
Dictionary(grouping: sortedContacts) { contact in
|
||||||
|
sectionTitle(for: contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sectionTitles: [String] {
|
||||||
|
contactsBySection.keys.sorted { lhs, rhs in
|
||||||
|
if lhs == "#" { return false }
|
||||||
|
if rhs == "#" { return true }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showsJumpIndex: Bool {
|
||||||
|
sortedContacts.count >= Design.Contacts.sectionIndexMinContacts
|
||||||
|
&& sectionTitles.count >= Design.Contacts.sectionIndexMinSections
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ZStack(alignment: .trailing) {
|
||||||
|
List {
|
||||||
|
ForEach(sectionTitles, id: \.self) { title in
|
||||||
|
let sectionContacts = contactsBySection[title] ?? []
|
||||||
|
Section {
|
||||||
|
ForEach(sectionContacts) { contact in
|
||||||
|
NavigationLink(value: contact) {
|
||||||
|
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
for index in indexSet {
|
||||||
|
contactsStore.deleteContact(sectionContacts[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(title)
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
.id(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationDestination(for: Contact.self) { contact in
|
||||||
|
ContactDetailView(contact: contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showsJumpIndex {
|
||||||
|
VStack(spacing: Design.Contacts.sectionIndexLetterSpacing) {
|
||||||
|
ForEach(sectionTitles, id: \.self) { title in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: Design.Contacts.sectionIndexScrollAnimationDuration)) {
|
||||||
|
proxy.scrollTo(title, anchor: .top)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(title)
|
||||||
|
.typography(.caption2)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.frame(
|
||||||
|
width: Design.Contacts.sectionIndexLetterWidth,
|
||||||
|
height: Design.Contacts.sectionIndexLetterHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.padding(.horizontal, Design.Spacing.xxSmall)
|
||||||
|
.background(Color.AppBackground.base.opacity(0.86))
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
.padding(.trailing, Design.Spacing.xxSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionTitle(for contact: Contact) -> String {
|
||||||
|
let key = sortKey(for: contact.name)
|
||||||
|
guard let first = key.first else { return "#" }
|
||||||
|
let letter = String(first).uppercased()
|
||||||
|
return letter.rangeOfCharacter(from: .letters) != nil ? letter : "#"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorts by last-name-first when possible, falling back to full name.
|
||||||
|
private func sortKey(for fullName: String) -> String {
|
||||||
|
let trimmed = fullName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "" }
|
||||||
|
|
||||||
|
let parts = trimmed.split(whereSeparator: \.isWhitespace).map(String.init)
|
||||||
|
guard let last = parts.last else { return trimmed }
|
||||||
|
|
||||||
|
if parts.count <= 1 {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
let given = parts.dropLast().joined(separator: " ")
|
||||||
|
return "\(last), \(given)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,8 @@ struct ContactDetailView: View {
|
|||||||
@Bindable var contact: Contact
|
@Bindable var contact: Contact
|
||||||
|
|
||||||
@State private var showingDeleteConfirmation = false
|
@State private var showingDeleteConfirmation = false
|
||||||
@State private var showingMoreActions = false
|
@State private var showingEditContact = false
|
||||||
@State private var showingAddTag = false
|
@State private var noteEditorTarget: NoteEditorTarget?
|
||||||
@State private var showingAddNote = false
|
|
||||||
@State private var newTag = ""
|
|
||||||
|
|
||||||
// Photo picker state
|
// Photo picker state
|
||||||
@State private var showingPhotoSourcePicker = false
|
@State private var showingPhotoSourcePicker = false
|
||||||
@ -28,141 +26,51 @@ struct ContactDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
|
Color.AppBackground.base
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
// Header banner with photo
|
ContactDetailCard(
|
||||||
ContactBannerView(
|
|
||||||
contact: contact,
|
contact: contact,
|
||||||
onEditPhoto: { showingPhotoSourcePicker = true }
|
onEditPhoto: { showingPhotoSourcePicker = true },
|
||||||
|
onAddNote: { noteEditorTarget = NoteEditorTarget(editingNote: nil) },
|
||||||
|
onSelectNote: { note in
|
||||||
|
noteEditorTarget = NoteEditorTarget(editingNote: note)
|
||||||
|
},
|
||||||
|
openURL: openURL
|
||||||
)
|
)
|
||||||
|
.frame(maxWidth: Design.CardSize.maxCardWidth)
|
||||||
|
|
||||||
// Content
|
// Spacer for bottom action bar
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
Spacer()
|
||||||
// Name
|
.frame(height: Design.Spacing.xLarge * 4)
|
||||||
Text(contact.name.isEmpty ? String.localized("Contact") : contact.name)
|
|
||||||
.typography(.hero)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
|
|
||||||
// Connection details
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
||||||
Text("Connection details")
|
|
||||||
.typography(.subheading)
|
|
||||||
.foregroundStyle(Color.Text.tertiary)
|
|
||||||
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
Image(systemName: "calendar")
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
Text(contact.lastSharedDate, format: .dateTime.day().month().year().hour().minute())
|
|
||||||
.typography(.subheading)
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
ForEach(contact.tagList, id: \.self) { tag in
|
|
||||||
TagPill(text: tag, onDelete: {
|
|
||||||
removeTag(tag)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showingAddTag = true
|
|
||||||
} label: {
|
|
||||||
Label(String.localized("Add tag"), systemImage: "plus")
|
|
||||||
.typography(.subheading)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.AppText.inverted)
|
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
|
||||||
.padding(.vertical, Design.Spacing.small)
|
|
||||||
.background(Color.Text.primary)
|
|
||||||
.clipShape(.capsule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contact info card - shows both legacy fields and new contact fields
|
|
||||||
ContactInfoCard(contact: contact, openURL: openURL)
|
|
||||||
|
|
||||||
// Notes section
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
||||||
Text("Notes")
|
|
||||||
.typography(.heading)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
|
|
||||||
if contact.notes.isEmpty {
|
|
||||||
NotesEmptyState()
|
|
||||||
} else {
|
|
||||||
Text(contact.notes)
|
|
||||||
.typography(.body)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(Design.Spacing.medium)
|
|
||||||
.background(Color.AppBackground.card)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showingAddNote = true
|
|
||||||
} label: {
|
|
||||||
Label(String.localized("Add note"), systemImage: "plus")
|
|
||||||
.typography(.subheading)
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
|
||||||
.background(Color.AppBackground.card)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
|
||||||
.stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, Design.Spacing.medium)
|
|
||||||
|
|
||||||
// Spacer for bottom bar
|
|
||||||
Spacer()
|
|
||||||
.frame(height: Design.Spacing.xLarge * 4)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
|
||||||
.padding(.top, Design.Spacing.large)
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.xLarge)
|
||||||
}
|
}
|
||||||
.background(Color.AppBackground.secondary)
|
.scrollIndicators(.hidden)
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
|
|
||||||
// Bottom action bar
|
// Bottom action bar
|
||||||
BottomActionBar(
|
BottomActionBar(
|
||||||
onMore: { showingMoreActions = true },
|
shareText: shareText,
|
||||||
onAddTag: { showingAddTag = true },
|
contactName: contact.name,
|
||||||
onAddNote: { showingAddNote = true }
|
onDelete: { showingDeleteConfirmation = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
// Edit action - could navigate to edit mode
|
showingEditContact = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.pencil")
|
Image(systemName: "pencil")
|
||||||
.foregroundStyle(Color.AppText.inverted)
|
.foregroundStyle(Color.Text.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
.confirmationDialog(String.localized("More"), isPresented: $showingMoreActions) {
|
|
||||||
Button(String.localized("Download contact")) {
|
|
||||||
// Download action
|
|
||||||
}
|
|
||||||
Button(String.localized("Share contact")) {
|
|
||||||
// Share action
|
|
||||||
}
|
|
||||||
Button(String.localized("Delete Contact"), role: .destructive) {
|
|
||||||
showingDeleteConfirmation = true
|
|
||||||
}
|
|
||||||
Button(String.localized("Cancel"), role: .cancel) { }
|
|
||||||
}
|
|
||||||
.alert(String.localized("Delete Contact"), isPresented: $showingDeleteConfirmation) {
|
.alert(String.localized("Delete Contact"), isPresented: $showingDeleteConfirmation) {
|
||||||
Button(String.localized("Cancel"), role: .cancel) { }
|
Button(String.localized("Cancel"), role: .cancel) { }
|
||||||
Button(String.localized("Delete"), role: .destructive) {
|
Button(String.localized("Delete"), role: .destructive) {
|
||||||
@ -172,17 +80,11 @@ struct ContactDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to delete this contact?")
|
Text("Are you sure you want to delete this contact?")
|
||||||
}
|
}
|
||||||
.alert(String.localized("Add Tag"), isPresented: $showingAddTag) {
|
.sheet(item: $noteEditorTarget) { target in
|
||||||
TextField(String.localized("Tag name"), text: $newTag)
|
AddNoteSheet(notes: $contact.notes, editingNote: target.editingNote)
|
||||||
Button(String.localized("Cancel"), role: .cancel) {
|
|
||||||
newTag = ""
|
|
||||||
}
|
|
||||||
Button(String.localized("Add")) {
|
|
||||||
addTag()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddNote) {
|
.sheet(isPresented: $showingEditContact) {
|
||||||
AddNoteSheet(notes: $contact.notes)
|
AddContactSheet(contact: contact)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
|
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
|
||||||
guard let action = pendingAction else { return }
|
guard let action = pendingAction else { return }
|
||||||
@ -243,31 +145,219 @@ struct ContactDetailView: View {
|
|||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addTag() {
|
private var shareText: String {
|
||||||
let tag = newTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
var lines: [String] = []
|
||||||
guard !tag.isEmpty else { return }
|
lines.append(contact.name.isEmpty ? String.localized("Contact") : contact.name)
|
||||||
|
|
||||||
if contact.tags.isEmpty {
|
if !contact.role.isEmpty || !contact.company.isEmpty {
|
||||||
contact.tags = tag
|
lines.append([contact.role, contact.company].filter { !$0.isEmpty }.joined(separator: " at "))
|
||||||
} else {
|
|
||||||
contact.tags += ", \(tag)"
|
|
||||||
}
|
}
|
||||||
newTag = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeTag(_ tag: String) {
|
for field in contact.sortedContactFields where !field.value.isEmpty {
|
||||||
var tags = contact.tagList
|
let title = field.title.isEmpty ? field.displayName : field.title
|
||||||
tags.removeAll { $0 == tag }
|
lines.append("\(title): \(field.displayValue)")
|
||||||
contact.tags = tags.joined(separator: ", ")
|
}
|
||||||
|
|
||||||
|
let noteItems = ContactNoteCodec.decode(contact.notes)
|
||||||
|
if !noteItems.isEmpty {
|
||||||
|
lines.append("")
|
||||||
|
lines.append(String.localized("Notes") + ":")
|
||||||
|
for note in noteItems {
|
||||||
|
lines.append("- \(note.summary)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct NoteEditorTarget: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let editingNote: ContactNoteItem?
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Banner View
|
// MARK: - Banner View
|
||||||
|
|
||||||
|
private struct ContactDetailCard: View {
|
||||||
|
let contact: Contact
|
||||||
|
let onEditPhoto: () -> Void
|
||||||
|
let onAddNote: () -> Void
|
||||||
|
let onSelectNote: (ContactNoteItem) -> Void
|
||||||
|
let openURL: (String) -> Void
|
||||||
|
|
||||||
|
private var noteItems: [ContactNoteItem] {
|
||||||
|
ContactNoteCodec
|
||||||
|
.decode(contact.notes)
|
||||||
|
.enumerated()
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
let leftDate = lhs.element.createdAt ?? .distantPast
|
||||||
|
let rightDate = rhs.element.createdAt ?? .distantPast
|
||||||
|
if leftDate == rightDate {
|
||||||
|
// Keep most recently added entries first when timestamps match/missing.
|
||||||
|
return lhs.offset > rhs.offset
|
||||||
|
}
|
||||||
|
return leftDate > rightDate
|
||||||
|
}
|
||||||
|
.map(\.element)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ContactBannerView(contact: contact, onEditPhoto: onEditPhoto)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(contact.name.isEmpty ? String.localized("Contact") : contact.name)
|
||||||
|
.typography(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
if !contact.role.isEmpty {
|
||||||
|
Text(contact.role)
|
||||||
|
.typography(.heading)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contact.company.isEmpty {
|
||||||
|
Text(contact.company)
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
Text("Connection details")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text(contact.lastSharedDate, format: .dateTime.day().month().year().hour().minute())
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contact.tagList.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ForEach(contact.tagList, id: \.self) { tag in
|
||||||
|
TagPill(text: tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
|
|
||||||
|
ContactInfoCard(contact: contact, openURL: openURL)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
HStack {
|
||||||
|
Text("Notes")
|
||||||
|
.typography(.heading)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Add", action: onAddNote)
|
||||||
|
.typography(.subheading)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
if noteItems.isEmpty {
|
||||||
|
Text("No notes yet")
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
Text("Tap Add to save context for this contact.")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
} else {
|
||||||
|
ForEach(Array(noteItems.enumerated()), id: \.offset) { index, note in
|
||||||
|
Button {
|
||||||
|
onSelectNote(note)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(note.summary)
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Text(note.timestampDisplay)
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if index < noteItems.count - 1 {
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(Color.AppBackground.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.bottom, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusLarge,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct ContactBannerView: View {
|
private struct ContactBannerView: View {
|
||||||
let contact: Contact
|
let contact: Contact
|
||||||
let onEditPhoto: () -> Void
|
let onEditPhoto: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Button(action: onEditPhoto) {
|
||||||
|
ContactProfileAvatarView(contact: contact)
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
|
.padding(Design.Spacing.xSmall)
|
||||||
|
.background(Color.CardPalette.coral)
|
||||||
|
.clipShape(.circle)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.standard)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(contact.photoData == nil ? String.localized("Add photo") : String.localized("Edit photo"))
|
||||||
|
.padding(.top, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.bottom, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tag Pill
|
||||||
|
|
||||||
|
private struct ContactProfileAvatarView: View {
|
||||||
|
let contact: Contact
|
||||||
|
|
||||||
private var initials: String {
|
private var initials: String {
|
||||||
let parts = contact.name.split(separator: " ")
|
let parts = contact.name.split(separator: " ")
|
||||||
if parts.count >= 2 {
|
if parts.count >= 2 {
|
||||||
@ -279,89 +369,38 @@ private struct ContactBannerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
Group {
|
||||||
// Background: photo or gradient
|
|
||||||
if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
|
if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(height: Design.CardSize.bannerHeight * 1.5)
|
|
||||||
.clipped()
|
|
||||||
.overlay(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.clear, .black.opacity(Design.Opacity.light)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Gradient background
|
Text(initials)
|
||||||
LinearGradient(
|
.typography(.title3)
|
||||||
colors: [
|
.bold()
|
||||||
Color.CardPalette.coral,
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
Color.CardPalette.coral.opacity(Design.Opacity.strong)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
],
|
.background(Color.CardPalette.coral)
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
|
|
||||||
// Decorative circles
|
|
||||||
Circle()
|
|
||||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
|
||||||
.frame(width: Design.CardSize.qrSize, height: Design.CardSize.qrSize)
|
|
||||||
.offset(y: -Design.Spacing.xLarge)
|
|
||||||
|
|
||||||
// Initials
|
|
||||||
VStack(spacing: Design.Spacing.xxSmall) {
|
|
||||||
Text(String(initials.prefix(1)))
|
|
||||||
.typography(.title2)
|
|
||||||
Text(String(initials.dropFirst().prefix(1)))
|
|
||||||
.typography(.title2)
|
|
||||||
}
|
|
||||||
.foregroundStyle(Color.white.opacity(Design.Opacity.accent))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit photo button
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button(action: onEditPhoto) {
|
|
||||||
Image(systemName: contact.photoData == nil ? "camera.fill" : "pencil")
|
|
||||||
.typography(.body)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(Design.Spacing.medium)
|
|
||||||
.background(.ultraThinMaterial)
|
|
||||||
.clipShape(.circle)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(contact.photoData == nil ? String.localized("Add photo") : String.localized("Change photo"))
|
|
||||||
}
|
|
||||||
.padding(Design.Spacing.large)
|
|
||||||
.padding(.top, Design.Spacing.xLarge)
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: Design.CardSize.bannerHeight * 1.5)
|
.frame(width: Design.Contacts.detailHeaderAvatarSize, height: Design.Contacts.detailHeaderAvatarSize)
|
||||||
|
.clipShape(.circle)
|
||||||
|
.overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusSmall,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetSmall
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tag Pill
|
|
||||||
|
|
||||||
private struct TagPill: View {
|
private struct TagPill: View {
|
||||||
let text: String
|
let text: String
|
||||||
let onDelete: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
Text(text)
|
||||||
Text(text)
|
.typography(.subheading)
|
||||||
.typography(.subheading)
|
|
||||||
Button {
|
|
||||||
onDelete()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.typography(.caption2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.small)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
@ -412,7 +451,7 @@ private struct ContactInfoCard: View {
|
|||||||
|
|
||||||
if index < allContactFields.count - 1 || hasLegacyFields {
|
if index < allContactFields.count - 1 || hasLegacyFields {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
.padding(.leading, Design.Contacts.detailFieldIconWidth + Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,7 +466,7 @@ private struct ContactInfoCard: View {
|
|||||||
|
|
||||||
if !contact.email.isEmpty && contact.emailAddresses.isEmpty {
|
if !contact.email.isEmpty && contact.emailAddresses.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
.padding(.leading, Design.Contacts.detailFieldIconWidth + Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,29 +498,28 @@ private struct ContactFieldInfoRow: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
||||||
// Icon circle
|
|
||||||
field.iconImage()
|
field.iconImage()
|
||||||
.typography(.body)
|
.typography(.subheading)
|
||||||
.foregroundStyle(Color.white)
|
.foregroundStyle(Color.CardPalette.coral)
|
||||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
.frame(width: Design.Contacts.detailFieldIconWidth)
|
||||||
.background(Color.CardPalette.coral)
|
|
||||||
.clipShape(.circle)
|
|
||||||
|
|
||||||
// Text
|
VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
|
||||||
Text(field.displayValue)
|
Text(field.displayValue)
|
||||||
.typography(.body)
|
.typography(.subheading)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(Design.Contacts.notePreviewLineLimit)
|
||||||
|
|
||||||
Text(field.title.isEmpty ? field.displayName : field.title)
|
Text(field.title.isEmpty ? field.displayName : field.title)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(Color.Text.tertiary)
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@ -497,28 +535,28 @@ private struct ContactInfoRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
||||||
// Icon circle
|
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.typography(.body)
|
.typography(.subheading)
|
||||||
.foregroundStyle(Color.white)
|
.foregroundStyle(Color.CardPalette.coral)
|
||||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
.frame(width: Design.Contacts.detailFieldIconWidth)
|
||||||
.background(Color.CardPalette.coral)
|
|
||||||
.clipShape(.circle)
|
|
||||||
|
|
||||||
// Text
|
VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
|
||||||
Text(value)
|
Text(value)
|
||||||
.typography(.body)
|
.typography(.subheading)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(Design.Contacts.notePreviewLineLimit)
|
||||||
Text(label)
|
Text(label)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(Color.Text.tertiary)
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@ -548,14 +586,14 @@ private struct NotesEmptyState: View {
|
|||||||
// MARK: - Bottom Action Bar
|
// MARK: - Bottom Action Bar
|
||||||
|
|
||||||
private struct BottomActionBar: View {
|
private struct BottomActionBar: View {
|
||||||
let onMore: () -> Void
|
let shareText: String
|
||||||
let onAddTag: () -> Void
|
let contactName: String
|
||||||
let onAddNote: () -> Void
|
let onDelete: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Button(action: onMore) {
|
ShareLink(item: shareText, subject: Text(contactName)) {
|
||||||
Text("More...")
|
Text("Share")
|
||||||
.typography(.subheading)
|
.typography(.subheading)
|
||||||
.bold()
|
.bold()
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
@ -564,26 +602,16 @@ private struct BottomActionBar: View {
|
|||||||
.background(Color.AppBackground.card)
|
.background(Color.AppBackground.card)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button(action: onAddTag) {
|
Button(role: .destructive, action: onDelete) {
|
||||||
Text("Add tag")
|
Text("Delete")
|
||||||
.typography(.subheading)
|
.typography(.subheading)
|
||||||
.bold()
|
.bold()
|
||||||
.foregroundStyle(Color.AppText.inverted)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
.background(Color.Text.primary)
|
.background(Color.Accent.red)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: onAddNote) {
|
|
||||||
Text("Add note")
|
|
||||||
.typography(.subheading)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.AppText.inverted)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
|
||||||
.background(Color.Text.primary)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -598,13 +626,14 @@ private struct BottomActionBar: View {
|
|||||||
private struct AddNoteSheet: View {
|
private struct AddNoteSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Binding var notes: String
|
@Binding var notes: String
|
||||||
@State private var editedNotes: String = ""
|
let editingNote: ContactNoteItem?
|
||||||
|
@State private var newNote: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
TextEditor(text: $editedNotes)
|
TextEditor(text: $newNote)
|
||||||
.padding(Design.Spacing.medium)
|
.padding(Design.Spacing.medium)
|
||||||
.navigationTitle(String.localized("Notes"))
|
.navigationTitle(editingNote == nil ? String.localized("Add Note") : String.localized("Edit Note"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.keyboardDismissable()
|
.keyboardDismissable()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@ -615,19 +644,120 @@ private struct AddNoteSheet: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(String.localized("Save")) {
|
Button(String.localized("Save")) {
|
||||||
notes = editedNotes
|
let trimmed = newNote.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
|
||||||
|
notes = ContactNoteCodec.upsert(
|
||||||
|
raw: notes,
|
||||||
|
at: editingNote?.sourceIndex,
|
||||||
|
summary: trimmed,
|
||||||
|
updatedAt: .now
|
||||||
|
)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.disabled(newNote.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if editingNote != nil {
|
||||||
|
ToolbarItem(placement: .bottomBar) {
|
||||||
|
Button(String.localized("Delete"), role: .destructive) {
|
||||||
|
notes = ContactNoteCodec.delete(raw: notes, at: editingNote?.sourceIndex)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
editedNotes = notes
|
newNote = editingNote?.summary ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ContactNoteItem {
|
||||||
|
let sourceIndex: Int
|
||||||
|
let summary: String
|
||||||
|
let createdAt: Date?
|
||||||
|
|
||||||
|
var timestampDisplay: String {
|
||||||
|
guard let createdAt else { return String.localized("Unknown time") }
|
||||||
|
return createdAt.formatted(.dateTime.month().day().hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ContactNoteCodec {
|
||||||
|
private static let iso8601: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let prefix = "@note("
|
||||||
|
private static let separator = "): "
|
||||||
|
|
||||||
|
static func encode(summary: String, createdAt: Date) -> String {
|
||||||
|
let normalized = summary
|
||||||
|
.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return "\(prefix)\(iso8601.string(from: createdAt))\(separator)\(normalized)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ raw: String) -> [ContactNoteItem] {
|
||||||
|
let blocks = raw
|
||||||
|
.components(separatedBy: "\n\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
return blocks.enumerated().map { index, block in
|
||||||
|
guard block.hasPrefix(prefix),
|
||||||
|
let closeRange = block.range(of: separator),
|
||||||
|
closeRange.lowerBound > block.startIndex else {
|
||||||
|
return ContactNoteItem(sourceIndex: index, summary: block, createdAt: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestampStart = block.index(block.startIndex, offsetBy: prefix.count)
|
||||||
|
let timestamp = String(block[timestampStart..<closeRange.lowerBound])
|
||||||
|
let summaryStart = closeRange.upperBound
|
||||||
|
let summary = String(block[summaryStart...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let date = iso8601.date(from: timestamp)
|
||||||
|
return ContactNoteItem(
|
||||||
|
sourceIndex: index,
|
||||||
|
summary: summary.isEmpty ? String.localized("Empty note") : summary,
|
||||||
|
createdAt: date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func upsert(raw: String, at sourceIndex: Int?, summary: String, updatedAt: Date) -> String {
|
||||||
|
let encoded = encode(summary: summary, createdAt: updatedAt)
|
||||||
|
var blocks = splitBlocks(raw)
|
||||||
|
|
||||||
|
if let sourceIndex, blocks.indices.contains(sourceIndex) {
|
||||||
|
blocks[sourceIndex] = encoded
|
||||||
|
} else {
|
||||||
|
blocks.append(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.joined(separator: "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(raw: String, at sourceIndex: Int?) -> String {
|
||||||
|
guard let sourceIndex else { return raw }
|
||||||
|
var blocks = splitBlocks(raw)
|
||||||
|
guard blocks.indices.contains(sourceIndex) else { return raw }
|
||||||
|
blocks.remove(at: sourceIndex)
|
||||||
|
return blocks.joined(separator: "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func splitBlocks(_ raw: String) -> [String] {
|
||||||
|
raw
|
||||||
|
.components(separatedBy: "\n\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ContactDetailView(
|
ContactDetailView(
|
||||||
|
|||||||
@ -7,6 +7,8 @@ struct AddContactSheet: View {
|
|||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let contact: Contact?
|
||||||
|
|
||||||
// Photo
|
// Photo
|
||||||
@State private var photoData: Data?
|
@State private var photoData: Data?
|
||||||
@State private var showingPhotoSourcePicker = false
|
@State private var showingPhotoSourcePicker = false
|
||||||
@ -34,6 +36,8 @@ struct AddContactSheet: View {
|
|||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
@State private var notes = ""
|
@State private var notes = ""
|
||||||
|
@State private var tags = ""
|
||||||
|
@State private var didLoadExistingValues = false
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
let hasName = !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
let hasName = !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
||||||
@ -69,6 +73,14 @@ struct AddContactSheet: View {
|
|||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isEditing: Bool {
|
||||||
|
contact != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init(contact: Contact? = nil) {
|
||||||
|
self.contact = contact
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@ -177,6 +189,16 @@ struct AddContactSheet: View {
|
|||||||
Text("Links")
|
Text("Links")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField(String.localized("e.g. VIP, Lead, Realtor"), text: $tags)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.words)
|
||||||
|
} header: {
|
||||||
|
Text("Tags")
|
||||||
|
} footer: {
|
||||||
|
Text("Separate tags with commas")
|
||||||
|
}
|
||||||
|
|
||||||
// Notes section
|
// Notes section
|
||||||
Section {
|
Section {
|
||||||
TextField(String.localized("Notes about this contact..."), text: $notes, axis: .vertical)
|
TextField(String.localized("Notes about this contact..."), text: $notes, axis: .vertical)
|
||||||
@ -185,9 +207,12 @@ struct AddContactSheet: View {
|
|||||||
Text("Notes")
|
Text("Notes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(String.localized("New contact"))
|
.navigationTitle(isEditing ? String.localized("Edit contact") : String.localized("New contact"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.keyboardDismissable()
|
.keyboardDismissable()
|
||||||
|
.onAppear {
|
||||||
|
loadExistingValuesIfNeeded()
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
|
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
|
||||||
guard let action = pendingAction else { return }
|
guard let action = pendingAction else { return }
|
||||||
pendingAction = nil
|
pendingAction = nil
|
||||||
@ -309,17 +334,78 @@ struct AddContactSheet: View {
|
|||||||
orderIndex += 1
|
orderIndex += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
appState.contactsStore.createContact(
|
let trimmedTags = tags
|
||||||
name: fullName,
|
.split(separator: ",")
|
||||||
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
.filter { !$0.isEmpty }
|
||||||
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
.joined(separator: ", ")
|
||||||
followUpDate: appState.appSettings.defaultFollowUpPreset.followUpDate(from: .now),
|
|
||||||
contactFields: contactFields,
|
if let contact {
|
||||||
photoData: photoData
|
appState.contactsStore.updateContact(
|
||||||
)
|
contact,
|
||||||
|
name: fullName,
|
||||||
|
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
tags: trimmedTags,
|
||||||
|
followUpDate: contact.followUpDate,
|
||||||
|
contactFields: contactFields,
|
||||||
|
photoData: photoData
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
appState.contactsStore.createContact(
|
||||||
|
name: fullName,
|
||||||
|
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
tags: trimmedTags,
|
||||||
|
followUpDate: appState.appSettings.defaultFollowUpPreset.followUpDate(from: .now),
|
||||||
|
contactFields: contactFields,
|
||||||
|
photoData: photoData
|
||||||
|
)
|
||||||
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadExistingValuesIfNeeded() {
|
||||||
|
guard !didLoadExistingValues, let contact else { return }
|
||||||
|
didLoadExistingValues = true
|
||||||
|
|
||||||
|
let nameParts = contact.name.split(separator: " ", maxSplits: 1).map(String.init)
|
||||||
|
firstName = nameParts.first ?? ""
|
||||||
|
lastName = nameParts.count > 1 ? nameParts[1] : ""
|
||||||
|
|
||||||
|
jobTitle = contact.role
|
||||||
|
company = contact.company
|
||||||
|
notes = contact.notes
|
||||||
|
tags = contact.tags
|
||||||
|
photoData = contact.photoData
|
||||||
|
|
||||||
|
let existingPhones = contact.phoneNumbers.map {
|
||||||
|
LabeledEntry(label: $0.title.isEmpty ? "Cell" : $0.title, value: $0.value)
|
||||||
|
}
|
||||||
|
if !existingPhones.isEmpty {
|
||||||
|
phoneEntries = existingPhones
|
||||||
|
} else if !contact.phone.isEmpty {
|
||||||
|
phoneEntries = [LabeledEntry(label: "Cell", value: contact.phone)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingEmails = contact.emailAddresses.map {
|
||||||
|
LabeledEntry(label: $0.title.isEmpty ? "Work" : $0.title, value: $0.value)
|
||||||
|
}
|
||||||
|
if !existingEmails.isEmpty {
|
||||||
|
emailEntries = existingEmails
|
||||||
|
} else if !contact.email.isEmpty {
|
||||||
|
emailEntries = [LabeledEntry(label: "Work", value: contact.email)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingLinks = contact.links.map {
|
||||||
|
LabeledEntry(label: $0.title.isEmpty ? "Website" : $0.title, value: $0.value)
|
||||||
|
}
|
||||||
|
if !existingLinks.isEmpty {
|
||||||
|
linkEntries = existingLinks
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@ -221,6 +221,8 @@ struct OnboardingView: View {
|
|||||||
switch status {
|
switch status {
|
||||||
case .authorized:
|
case .authorized:
|
||||||
.allowed
|
.allowed
|
||||||
|
case .limited:
|
||||||
|
.allowed
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
.notRequested
|
.notRequested
|
||||||
case .denied, .restricted:
|
case .denied, .restricted:
|
||||||
|
|||||||
@ -24,6 +24,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Settings Sections
|
// MARK: - Settings Sections
|
||||||
appearanceSection
|
appearanceSection
|
||||||
|
iCloudSyncSection
|
||||||
cardsSection
|
cardsSection
|
||||||
sharingSection
|
sharingSection
|
||||||
contactsSection
|
contactsSection
|
||||||
@ -89,6 +90,66 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - iCloud Sync Section
|
||||||
|
|
||||||
|
private var iCloudSyncSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
SettingsSectionHeader(
|
||||||
|
title: "iCloud",
|
||||||
|
systemImage: "icloud",
|
||||||
|
accentColor: AppThemeAccent.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsCard(
|
||||||
|
backgroundColor: Color.AppBackground.elevated,
|
||||||
|
borderColor: AppBorder.standard
|
||||||
|
) {
|
||||||
|
SettingsCardRow {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: appState.cloudKitSyncMonitor.statusIcon)
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(syncIconColor)
|
||||||
|
.symbolEffect(.variableColor.iterative.reversing, isActive: appState.cloudKitSyncMonitor.state == .syncing)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
|
||||||
|
Text("iCloud Sync")
|
||||||
|
.typography(.bodyEmphasis)
|
||||||
|
.foregroundStyle(Color.AppText.primary)
|
||||||
|
|
||||||
|
Text(appState.cloudKitSyncMonitor.statusText)
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(syncStatusColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var syncIconColor: Color {
|
||||||
|
switch appState.cloudKitSyncMonitor.state {
|
||||||
|
case .synced:
|
||||||
|
return AppStatus.success
|
||||||
|
case .error:
|
||||||
|
return AppStatus.error
|
||||||
|
case .syncing, .unknown:
|
||||||
|
return Color.AppText.secondary
|
||||||
|
case .disabled:
|
||||||
|
return Color.AppText.tertiary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var syncStatusColor: Color {
|
||||||
|
switch appState.cloudKitSyncMonitor.state {
|
||||||
|
case .error:
|
||||||
|
return AppStatus.error
|
||||||
|
default:
|
||||||
|
return Color.AppText.secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var versionFooter: some View {
|
private var versionFooter: some View {
|
||||||
Text(settingsState.versionString)
|
Text(settingsState.versionString)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
|
|||||||
@ -58,18 +58,12 @@ final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputOb
|
|||||||
|
|
||||||
private func startScanning() {
|
private func startScanning() {
|
||||||
guard let session = captureSession, !session.isRunning else { return }
|
guard let session = captureSession, !session.isRunning else { return }
|
||||||
let capturedSession = session
|
session.startRunning()
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
capturedSession.startRunning()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopScanning() {
|
private func stopScanning() {
|
||||||
guard let session = captureSession, session.isRunning else { return }
|
guard let session = captureSession, session.isRunning else { return }
|
||||||
let capturedSession = session
|
session.stopRunning()
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
capturedSession.stopRunning()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ struct ScannerOverlayView: View {
|
|||||||
let size = min(geometry.size.width, geometry.size.height) * 0.7
|
let size = min(geometry.size.width, geometry.size.height) * 0.7
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.opacity(Design.Opacity.medium)
|
Color.AppBackground.base.opacity(Design.Opacity.medium)
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
@ -27,7 +27,7 @@ struct ScannerOverlayView: View {
|
|||||||
.typography(.heading)
|
.typography(.heading)
|
||||||
.foregroundStyle(Color.Text.inverted)
|
.foregroundStyle(Color.Text.inverted)
|
||||||
.padding(Design.Spacing.medium)
|
.padding(Design.Spacing.medium)
|
||||||
.background(Color.black.opacity(Design.Opacity.medium))
|
.background(Color.AppBackground.base.opacity(Design.Opacity.medium))
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
.padding(.bottom, Design.Spacing.xxxLarge)
|
.padding(.bottom, Design.Spacing.xxxLarge)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,7 +101,7 @@ private struct QRCodeSection: View {
|
|||||||
QRCodeView(payload: card.vCardPayload)
|
QRCodeView(payload: card.vCardPayload)
|
||||||
.frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
|
.frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
.background(Color.white)
|
.background(Color.ShareSheet.rowBackground)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
|
||||||
// Instruction text
|
// Instruction text
|
||||||
@ -146,7 +146,7 @@ private struct AppClipSection: View {
|
|||||||
QRCodeView(payload: result.appClipURL.absoluteString)
|
QRCodeView(payload: result.appClipURL.absoluteString)
|
||||||
.frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
|
.frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
.background(Color.white)
|
.background(Color.ShareSheet.rowBackground)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
|
||||||
// Expiration notice
|
// Expiration notice
|
||||||
@ -190,7 +190,7 @@ private struct AppClipSection: View {
|
|||||||
if let error = appClipState.errorMessage {
|
if let error = appClipState.errorMessage {
|
||||||
Text(error)
|
Text(error)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(Color.Accent.red)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Bedrock
|
|||||||
/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
|
/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
|
||||||
struct AddedContactFieldsView: View {
|
struct AddedContactFieldsView: View {
|
||||||
@Binding var fields: [AddedContactField]
|
@Binding var fields: [AddedContactField]
|
||||||
var themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2)
|
var themeColor: Color = Color.Text.secondary
|
||||||
let onEdit: (AddedContactField) -> Void
|
let onEdit: (AddedContactField) -> Void
|
||||||
|
|
||||||
@State private var draggingField: AddedContactField?
|
@State private var draggingField: AddedContactField?
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import Bedrock
|
|||||||
|
|
||||||
/// Grid view for selecting contact field types to add
|
/// Grid view for selecting contact field types to add
|
||||||
struct ContactFieldPickerView: View {
|
struct ContactFieldPickerView: View {
|
||||||
var themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2)
|
var themeColor: Color = Color.Text.secondary
|
||||||
let onSelect: (ContactFieldType) -> Void
|
let onSelect: (ContactFieldType) -> Void
|
||||||
|
|
||||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.medium), count: 3)
|
private let columns = Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.medium), count: 3)
|
||||||
|
|||||||
@ -21,7 +21,7 @@ struct FieldRow: View {
|
|||||||
.overlay(
|
.overlay(
|
||||||
field.fieldType.iconImage()
|
field.fieldType.iconImage()
|
||||||
.typography(.title3)
|
.typography(.title3)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
)
|
)
|
||||||
|
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ struct FieldRowPreview: View {
|
|||||||
.overlay(
|
.overlay(
|
||||||
field.fieldType.iconImage()
|
field.fieldType.iconImage()
|
||||||
.typography(.title3)
|
.typography(.title3)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ struct FieldTypeButton: View {
|
|||||||
.overlay(
|
.overlay(
|
||||||
fieldType.iconImage()
|
fieldType.iconImage()
|
||||||
.typography(.title3)
|
.typography(.title3)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(fieldType.displayName)
|
Text(fieldType.displayName)
|
||||||
|
|||||||
@ -89,10 +89,10 @@ struct HeaderLayoutPickerView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Text("Confirm layout")
|
Text("Confirm layout")
|
||||||
.typography(.heading)
|
.typography(.heading)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, Design.Spacing.large)
|
.padding(.vertical, Design.Spacing.large)
|
||||||
.background(.black)
|
.background(Color.Accent.ink)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.xLarge)
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
|||||||
@ -17,7 +17,7 @@ struct LayoutBadge: View {
|
|||||||
.padding(.horizontal, Design.Spacing.small)
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
.clipShape(.capsule)
|
.clipShape(.capsule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
BusinessCardClip/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
BusinessCardClip/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors": [
|
||||||
|
{
|
||||||
|
"color": {
|
||||||
|
"color-space": "srgb",
|
||||||
|
"components": {
|
||||||
|
"alpha": "1.000",
|
||||||
|
"blue": "0.280",
|
||||||
|
"green": "0.330",
|
||||||
|
"red": "0.950"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom": "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances": [
|
||||||
|
{
|
||||||
|
"appearance": "luminosity",
|
||||||
|
"value": "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color": {
|
||||||
|
"color-space": "srgb",
|
||||||
|
"components": {
|
||||||
|
"alpha": "1.000",
|
||||||
|
"blue": "0.280",
|
||||||
|
"green": "0.330",
|
||||||
|
"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.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 |
@ -12,11 +12,11 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.icloud-services</key>
|
<key>com.apple.developer.icloud-services</key>
|
||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit-Anonymous</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.parent-application-identifiers</key>
|
<key>com.apple.developer.parent-application-identifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)</string>
|
<string>$(AppIdentifierPrefix)com.mbrucedogs.BusinessCard</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -1,37 +1,115 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppIntents
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BusinessCardClipApp: App {
|
struct BusinessCardClipApp: App {
|
||||||
@State private var recordName: String?
|
@State private var recordName: String?
|
||||||
|
@State private var launchErrorMessage: String?
|
||||||
|
#if DEBUG
|
||||||
|
@State private var debugState: ClipDebugState?
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
Group {
|
||||||
if let recordName {
|
#if DEBUG
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
if let recordName {
|
||||||
|
ClipRootView(recordName: recordName)
|
||||||
|
} else if let launchErrorMessage {
|
||||||
|
ClipErrorView(message: launchErrorMessage) {
|
||||||
|
self.launchErrorMessage = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ClipLoadingView()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||||
|
Design.debugLog("Clip: onContinueUserActivity fired, webpageURL=\(activity.webpageURL?.absoluteString ?? "nil")")
|
||||||
handleUserActivity(activity)
|
handleUserActivity(activity)
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
|
Design.debugLog("Clip: onOpenURL fired, url=\(url.absoluteString)")
|
||||||
handleURL(url)
|
handleURL(url)
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
Design.debugLog("Clip: .task running - checking launch sources")
|
||||||
|
#if DEBUG
|
||||||
|
if let dbg = parseDebugStateArgument() {
|
||||||
|
Design.debugLog("Clip: debug state from args: \(dbg.rawValue)")
|
||||||
|
debugState = dbg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if let envURL = ProcessInfo.processInfo.environment["_XCAppClipURL"],
|
||||||
|
let url = URL(string: envURL) {
|
||||||
|
Design.debugLog("Clip: _XCAppClipURL from env: \(envURL)")
|
||||||
|
handleURL(url)
|
||||||
|
} else {
|
||||||
|
Design.debugLog("Clip: no _XCAppClipURL in env, no debug args. recordName=\(recordName ?? "nil")")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleUserActivity(_ activity: NSUserActivity) {
|
private func handleUserActivity(_ activity: NSUserActivity) {
|
||||||
guard let url = activity.webpageURL else { return }
|
Design.debugLog("Clip: handleUserActivity, webpageURL=\(activity.webpageURL?.absoluteString ?? "nil")")
|
||||||
|
guard let url = activity.webpageURL else {
|
||||||
|
Design.debugLog("Clip: handleUserActivity - no webpageURL, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
handleURL(url)
|
handleURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleURL(_ url: URL) {
|
private func handleURL(_ url: URL) {
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
Design.debugLog("Clip: handleURL \(url.absoluteString)")
|
||||||
let id = components.queryItems?.first(where: { $0.name == "id" })?.value else {
|
guard let id = extractRecordName(from: url) else {
|
||||||
|
Design.debugLog("Clip: extractRecordName failed for \(url.absoluteString)")
|
||||||
|
launchErrorMessage = ClipError.invalidRecord.localizedDescription
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Design.debugLog("Clip: extracted recordName=\(id), setting recordName")
|
||||||
|
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: ", ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user