diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/bluesky.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/bluesky.symbolset/Contents.json
new file mode 100644
index 0000000..7051e49
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/bluesky.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "bluesky.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/bluesky.symbolset/bluesky.svg b/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/bluesky.symbolset/bluesky.svg
new file mode 100644
index 0000000..5152b4e
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/bluesky/bluesky.symbolset/bluesky.svg
@@ -0,0 +1,100 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/discord/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/discord/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/discord/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.fill.symbolset/Contents.json
new file mode 100644
index 0000000..3e33952
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.fill.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "discord.fill.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.fill.symbolset/discord.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.fill.symbolset/discord.fill.svg
new file mode 100644
index 0000000..4819b03
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.fill.symbolset/discord.fill.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.symbolset/Contents.json
new file mode 100644
index 0000000..24f139d
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "discord.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.symbolset/discord.svg b/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.symbolset/discord.svg
new file mode 100644
index 0000000..baa9398
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.symbolset/discord.svg
@@ -0,0 +1,199 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/discourse/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/discourse/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/discourse/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/discourse/discourse.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/discourse/discourse.fill.symbolset/Contents.json
new file mode 100644
index 0000000..c13eab2
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/discourse/discourse.fill.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "discourse.fill.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/discourse/discourse.fill.symbolset/discourse.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/discourse/discourse.fill.symbolset/discourse.fill.svg
new file mode 100644
index 0000000..d7f3666
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/discourse/discourse.fill.symbolset/discourse.fill.svg
@@ -0,0 +1,140 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/facebook/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/facebook/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/facebook/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/facebook/facebook.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/facebook/facebook.symbolset/Contents.json
new file mode 100644
index 0000000..48c82c2
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/facebook/facebook.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "facebook.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/facebook/facebook.symbolset/facebook.svg b/BusinessCard/Assets.xcassets/SocialSymbols/facebook/facebook.symbolset/facebook.svg
new file mode 100644
index 0000000..bd52680
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/facebook/facebook.symbolset/facebook.svg
@@ -0,0 +1,98 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/github/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/github/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/github/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/github/github.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/github/github.fill.symbolset/Contents.json
new file mode 100644
index 0000000..092d1f2
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/github/github.fill.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "github.fill.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/github/github.fill.symbolset/github.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/github/github.fill.symbolset/github.fill.svg
new file mode 100644
index 0000000..9964bc1
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/github/github.fill.symbolset/github.fill.svg
@@ -0,0 +1,167 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/instagram/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/instagram/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/instagram/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/instagram/instagram.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/instagram/instagram.symbolset/Contents.json
new file mode 100644
index 0000000..d24de41
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/instagram/instagram.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "instagram.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/instagram/instagram.symbolset/instagram.svg b/BusinessCard/Assets.xcassets/SocialSymbols/instagram/instagram.symbolset/instagram.svg
new file mode 100644
index 0000000..232cd44
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/instagram/instagram.symbolset/instagram.svg
@@ -0,0 +1,199 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/ko-fi.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/ko-fi.symbolset/Contents.json
new file mode 100644
index 0000000..2df279c
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/ko-fi.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "ko-fi.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/ko-fi.symbolset/ko-fi.svg b/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/ko-fi.symbolset/ko-fi.svg
new file mode 100644
index 0000000..c9d3a71
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/ko-fi.symbolset/ko-fi.svg
@@ -0,0 +1,157 @@
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/linkedin.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/linkedin.symbolset/Contents.json
new file mode 100644
index 0000000..ef8dc17
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/linkedin.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "linkedin.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/linkedin.symbolset/linkedin.svg b/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/linkedin.symbolset/linkedin.svg
new file mode 100644
index 0000000..860c395
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/linkedin/linkedin.symbolset/linkedin.svg
@@ -0,0 +1,108 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.fill.symbolset/Contents.json
new file mode 100644
index 0000000..d674f08
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.fill.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "mastodon.clean.fill.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.fill.symbolset/mastodon.clean.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.fill.symbolset/mastodon.clean.fill.svg
new file mode 100644
index 0000000..0f214e4
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.fill.symbolset/mastodon.clean.fill.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.symbolset/Contents.json
new file mode 100644
index 0000000..2480e81
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "mastodon.clean.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.symbolset/mastodon.clean.svg b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.symbolset/mastodon.clean.svg
new file mode 100644
index 0000000..bfe2810
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.symbolset/mastodon.clean.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.fill.symbolset/Contents.json
new file mode 100644
index 0000000..33f1832
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.fill.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "mastodon.fill.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.fill.symbolset/mastodon.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.fill.symbolset/mastodon.fill.svg
new file mode 100644
index 0000000..31998b5
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.fill.symbolset/mastodon.fill.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.symbolset/Contents.json
new file mode 100644
index 0000000..a359295
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "mastodon.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.symbolset/mastodon.svg b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.symbolset/mastodon.svg
new file mode 100644
index 0000000..73f8bb4
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.symbolset/mastodon.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/matrix/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/matrix/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/matrix/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/matrix/matrix.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/matrix/matrix.symbolset/Contents.json
new file mode 100644
index 0000000..e6bcffb
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/matrix/matrix.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "matrix.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/matrix/matrix.symbolset/matrix.svg b/BusinessCard/Assets.xcassets/SocialSymbols/matrix/matrix.symbolset/matrix.svg
new file mode 100644
index 0000000..9496958
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/matrix/matrix.symbolset/matrix.svg
@@ -0,0 +1,107 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/microblog/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/microblog/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/microblog/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/microblog/microblog.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/microblog/microblog.symbolset/Contents.json
new file mode 100644
index 0000000..16b866e
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/microblog/microblog.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "microblog.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/microblog/microblog.symbolset/microblog.svg b/BusinessCard/Assets.xcassets/SocialSymbols/microblog/microblog.symbolset/microblog.svg
new file mode 100644
index 0000000..83622a3
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/microblog/microblog.symbolset/microblog.svg
@@ -0,0 +1,107 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/patreon/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/patreon/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/patreon/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/patreon/patreon.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/patreon/patreon.fill.symbolset/Contents.json
new file mode 100644
index 0000000..2fdfb0b
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/patreon/patreon.fill.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "patreon.fill.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/patreon/patreon.fill.symbolset/patreon.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/patreon/patreon.fill.symbolset/patreon.fill.svg
new file mode 100644
index 0000000..b2c147f
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/patreon/patreon.fill.symbolset/patreon.fill.svg
@@ -0,0 +1,157 @@
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/reddit/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.fill.symbolset/Contents.json
new file mode 100644
index 0000000..e30ed4b
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.fill.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "reddit.fill.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.fill.symbolset/reddit.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.fill.symbolset/reddit.fill.svg
new file mode 100644
index 0000000..0858032
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.fill.symbolset/reddit.fill.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.symbolset/Contents.json
new file mode 100644
index 0000000..0c8031e
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "reddit.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.symbolset/reddit.svg b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.symbolset/reddit.svg
new file mode 100644
index 0000000..6e0d489
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.symbolset/reddit.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/slack/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/slack/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/slack/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/slack/slack.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/slack/slack.symbolset/Contents.json
new file mode 100644
index 0000000..2d058ab
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/slack/slack.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "slack.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/slack/slack.symbolset/slack.svg b/BusinessCard/Assets.xcassets/SocialSymbols/slack/slack.symbolset/slack.svg
new file mode 100644
index 0000000..aefc440
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/slack/slack.symbolset/slack.svg
@@ -0,0 +1,121 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/telegram/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/telegram/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/telegram/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/telegram/telegram.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/telegram/telegram.symbolset/Contents.json
new file mode 100644
index 0000000..6870b09
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/telegram/telegram.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "telegram.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/telegram/telegram.symbolset/telegram.svg b/BusinessCard/Assets.xcassets/SocialSymbols/telegram/telegram.symbolset/telegram.svg
new file mode 100644
index 0000000..ddee314
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/telegram/telegram.symbolset/telegram.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/threads/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/threads/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/threads/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/threads/threads.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/threads/threads.symbolset/Contents.json
new file mode 100644
index 0000000..9301141
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/threads/threads.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "threads.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/threads/threads.symbolset/threads.svg b/BusinessCard/Assets.xcassets/SocialSymbols/threads/threads.symbolset/threads.svg
new file mode 100644
index 0000000..0c68eff
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/threads/threads.symbolset/threads.svg
@@ -0,0 +1,96 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok-official.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok-official.symbolset/Contents.json
new file mode 100644
index 0000000..39d1738
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok-official.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "tiktok-official.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok-official.symbolset/tiktok-official.svg b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok-official.symbolset/tiktok-official.svg
new file mode 100644
index 0000000..8f79893
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok-official.symbolset/tiktok-official.svg
@@ -0,0 +1,114 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok.symbolset/Contents.json
new file mode 100644
index 0000000..c4bfc1d
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "tiktok.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok.symbolset/tiktok.svg b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok.symbolset/tiktok.svg
new file mode 100644
index 0000000..d9795c7
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok.symbolset/tiktok.svg
@@ -0,0 +1,100 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitch/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitch/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitch/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitch/twitch.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitch/twitch.symbolset/Contents.json
new file mode 100644
index 0000000..037822a
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitch/twitch.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "twitch.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitch/twitch.symbolset/twitch.svg b/BusinessCard/Assets.xcassets/SocialSymbols/twitch/twitch.symbolset/twitch.svg
new file mode 100644
index 0000000..7c38655
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitch/twitch.symbolset/twitch.svg
@@ -0,0 +1,230 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/tweetie.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/tweetie.symbolset/Contents.json
new file mode 100644
index 0000000..f9e114e
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/tweetie.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "tweetie.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/tweetie.symbolset/tweetie.svg b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/tweetie.symbolset/tweetie.svg
new file mode 100644
index 0000000..99994f0
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/tweetie.symbolset/tweetie.svg
@@ -0,0 +1,167 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/twitter.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/twitter.symbolset/Contents.json
new file mode 100644
index 0000000..8e264d5
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/twitter.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "twitter.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/twitter.symbolset/twitter.svg b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/twitter.symbolset/twitter.svg
new file mode 100644
index 0000000..c9a8e9b
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/twitter.symbolset/twitter.svg
@@ -0,0 +1,167 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/x-twitter.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/x-twitter.symbolset/Contents.json
new file mode 100644
index 0000000..b0744bc
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/x-twitter.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "x-twitter.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/x-twitter.symbolset/x-twitter.svg b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/x-twitter.symbolset/x-twitter.svg
new file mode 100644
index 0000000..e2d611b
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/x-twitter.symbolset/x-twitter.svg
@@ -0,0 +1,100 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/Contents.json
new file mode 100644
index 0000000..e07e8c2
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "youtube.fill.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/youtube.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/youtube.fill.svg
new file mode 100644
index 0000000..0af23b3
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/youtube.fill.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/Contents.json
new file mode 100644
index 0000000..331557e
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "youtube.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/youtube.svg b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/youtube.svg
new file mode 100644
index 0000000..35fba71
--- /dev/null
+++ b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/youtube.svg
@@ -0,0 +1,200 @@
+
+
+
+
diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift
index 89e31e4..d0f2ad2 100644
--- a/BusinessCard/BusinessCardApp.swift
+++ b/BusinessCard/BusinessCardApp.swift
@@ -9,7 +9,7 @@ struct BusinessCardApp: App {
init() {
// Use a simple configuration first - CloudKit can be enabled later
// when the project is properly configured in Xcode
- let schema = Schema([BusinessCard.self, Contact.self])
+ let schema = Schema([BusinessCard.self, Contact.self, ContactField.self])
// Try to create container with various fallback strategies
var container: ModelContainer?
diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift
index c06cee7..ac66d57 100644
--- a/BusinessCard/Design/DesignConstants.swift
+++ b/BusinessCard/Design/DesignConstants.swift
@@ -14,9 +14,17 @@ extension Design {
/// BusinessCard-specific size constants.
enum CardSize {
static let cardWidth: CGFloat = 320
- static let cardHeight: CGFloat = 200
+ static let cardHeight: CGFloat = 340
+ static let cardHeightCompact: CGFloat = 200
static let avatarSize: CGFloat = 56
+ static let avatarLarge: CGFloat = 80
+ static let avatarOverlap: CGFloat = 40
+ static let logoSize: CGFloat = 64
+ static let bannerHeight: CGFloat = 140
static let qrSize: CGFloat = 200
+ static let qrSizeLarge: CGFloat = 260
+ static let colorSwatchSize: CGFloat = 40
+ static let socialIconSize: CGFloat = 32
static let widgetPhoneWidth: CGFloat = 220
static let widgetPhoneHeight: CGFloat = 120
static let widgetWatchSize: CGFloat = 100
@@ -79,6 +87,32 @@ extension Color {
static let star = Color(red: 0.98, green: 0.82, blue: 0.34)
static let neutral = Color(red: 0.89, green: 0.89, blue: 0.9)
}
+
+ // MARK: - Share Sheet Dark Theme
+
+ enum ShareSheet {
+ static let background = Color(red: 0.18, green: 0.20, blue: 0.23)
+ static let cardBackground = Color(red: 0.24, green: 0.26, blue: 0.30)
+ static let rowBackground = Color(red: 0.30, green: 0.32, blue: 0.36)
+ static let text = Color(red: 0.96, green: 0.96, blue: 0.97)
+ static let secondaryText = Color(red: 0.70, green: 0.72, blue: 0.75)
+ }
+
+ // MARK: - Social Media Brand Colors
+
+ enum Social {
+ static let linkedIn = Color(red: 0.0, green: 0.47, blue: 0.71)
+ static let twitter = Color(red: 0.11, green: 0.63, blue: 0.95)
+ static let instagram = Color(red: 0.88, green: 0.19, blue: 0.42)
+ static let facebook = Color(red: 0.26, green: 0.40, blue: 0.70)
+ static let tiktok = Color(red: 0.0, green: 0.0, blue: 0.0)
+ static let github = Color(red: 0.13, green: 0.13, blue: 0.13)
+ static let threads = Color(red: 0.0, green: 0.0, blue: 0.0)
+ static let telegram = Color(red: 0.16, green: 0.63, blue: 0.89)
+ static let whatsapp = Color(red: 0.15, green: 0.68, blue: 0.38)
+ static let venmo = Color(red: 0.22, green: 0.53, blue: 0.79)
+ static let cashApp = Color(red: 0.0, green: 0.82, blue: 0.35)
+ }
}
// MARK: - Typealiases for easier migration
diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift
index b725335..4d4dc3b 100644
--- a/BusinessCard/Models/BusinessCard.swift
+++ b/BusinessCard/Models/BusinessCard.swift
@@ -10,7 +10,10 @@ final class BusinessCard {
var company: String
var label: String
var email: String
+ var emailLabel: String
var phone: String
+ var phoneLabel: String
+ var phoneExtension: String
var website: String
var location: String
var isDefault: Bool
@@ -20,15 +23,33 @@ final class BusinessCard {
var createdAt: Date
var updatedAt: Date
- // New fields for richer profiles
+ // Enhanced profile fields
+ var prefix: String
+ var firstName: String
+ var middleName: String
+ var lastName: String
+ var suffix: String
+ var maidenName: String
+ var preferredName: String
var pronouns: String
+ var department: String
+ var headline: String
var bio: String
+ var accreditations: String
+
+ // Social media
var linkedIn: String
var twitter: String
var instagram: String
var facebook: String
var tiktok: String
var github: String
+ var threads: String
+ var telegram: String
+ var venmo: String
+ var cashApp: String
+
+ // Custom links
var customLink1Title: String
var customLink1URL: String
var customLink2Title: String
@@ -36,6 +57,13 @@ final class BusinessCard {
// Profile photo stored as Data (JPEG)
@Attribute(.externalStorage) var photoData: Data?
+
+ // Company logo stored as Data (PNG)
+ @Attribute(.externalStorage) var logoData: Data?
+
+ // Contact fields array (ordered, supports multiples)
+ @Relationship(deleteRule: .cascade, inverse: \ContactField.card)
+ var contactFields: [ContactField]? = []
init(
id: UUID = UUID(),
@@ -44,7 +72,10 @@ final class BusinessCard {
company: String = "",
label: String = "Work",
email: String = "",
+ emailLabel: String = "Work",
phone: String = "",
+ phoneLabel: String = "Cell",
+ phoneExtension: String = "",
website: String = "",
location: String = "",
isDefault: Bool = false,
@@ -53,19 +84,34 @@ final class BusinessCard {
avatarSystemName: String = "person.crop.circle",
createdAt: Date = .now,
updatedAt: Date = .now,
+ prefix: String = "",
+ firstName: String = "",
+ middleName: String = "",
+ lastName: String = "",
+ suffix: String = "",
+ maidenName: String = "",
+ preferredName: String = "",
pronouns: String = "",
+ department: String = "",
+ headline: String = "",
bio: String = "",
+ accreditations: String = "",
linkedIn: String = "",
twitter: String = "",
instagram: String = "",
facebook: String = "",
tiktok: String = "",
github: String = "",
+ threads: String = "",
+ telegram: String = "",
+ venmo: String = "",
+ cashApp: String = "",
customLink1Title: String = "",
customLink1URL: String = "",
customLink2Title: String = "",
customLink2URL: String = "",
- photoData: Data? = nil
+ photoData: Data? = nil,
+ logoData: Data? = nil
) {
self.id = id
self.displayName = displayName
@@ -73,7 +119,10 @@ final class BusinessCard {
self.company = company
self.label = label
self.email = email
+ self.emailLabel = emailLabel
self.phone = phone
+ self.phoneLabel = phoneLabel
+ self.phoneExtension = phoneExtension
self.website = website
self.location = location
self.isDefault = isDefault
@@ -82,19 +131,34 @@ final class BusinessCard {
self.avatarSystemName = avatarSystemName
self.createdAt = createdAt
self.updatedAt = updatedAt
+ self.prefix = prefix
+ self.firstName = firstName
+ self.middleName = middleName
+ self.lastName = lastName
+ self.suffix = suffix
+ self.maidenName = maidenName
+ self.preferredName = preferredName
self.pronouns = pronouns
+ self.department = department
+ self.headline = headline
self.bio = bio
+ self.accreditations = accreditations
self.linkedIn = linkedIn
self.twitter = twitter
self.instagram = instagram
self.facebook = facebook
self.tiktok = tiktok
self.github = github
+ self.threads = threads
+ self.telegram = telegram
+ self.venmo = venmo
+ self.cashApp = cashApp
self.customLink1Title = customLink1Title
self.customLink1URL = customLink1URL
self.customLink2Title = customLink2Title
self.customLink2URL = customLink2URL
self.photoData = photoData
+ self.logoData = logoData
}
@MainActor
@@ -113,10 +177,149 @@ final class BusinessCard {
return base.appending(path: id.uuidString)
}
+ /// Ordered contact fields (non-nil, sorted by orderIndex)
+ var orderedContactFields: [ContactField] {
+ (contactFields ?? []).sorted { $0.orderIndex < $1.orderIndex }
+ }
+
+ /// Adds a new contact field
+ func addContactField(typeId: String, value: String = "", title: String = "") {
+ let newIndex = (contactFields ?? []).count
+ let field = ContactField(typeId: typeId, value: value, title: title, orderIndex: newIndex)
+ field.card = self
+ if contactFields == nil {
+ contactFields = []
+ }
+ contactFields?.append(field)
+ }
+
+ /// Adds a new contact field from ContactFieldType
+ func addContactField(_ type: ContactFieldType, value: String = "", title: String = "") {
+ addContactField(typeId: type.id, value: value, title: title)
+ }
+
+ /// Removes a contact field
+ func removeContactField(_ field: ContactField) {
+ contactFields?.removeAll { $0.id == field.id }
+ reorderContactFields()
+ }
+
+ /// Reorders contact fields after changes
+ func reorderContactFields() {
+ for (index, field) in orderedContactFields.enumerated() {
+ field.orderIndex = index
+ }
+ }
+
+ /// Gets the first contact field of a given type
+ func firstContactField(ofType typeId: String) -> ContactField? {
+ orderedContactFields.first { $0.typeId == typeId }
+ }
+
+ /// Gets all contact fields of a given type
+ func contactFields(ofType typeId: String) -> [ContactField] {
+ orderedContactFields.filter { $0.typeId == typeId }
+ }
+
+ /// Returns true if the card has any contact fields
+ var hasContactFields: Bool {
+ !(contactFields ?? []).isEmpty
+ }
+
/// Returns true if the card has any social media links
var hasSocialLinks: Bool {
- !linkedIn.isEmpty || !twitter.isEmpty || !instagram.isEmpty ||
- !facebook.isEmpty || !tiktok.isEmpty || !github.isEmpty
+ // Check new array first
+ let socialTypes: Set = ["linkedIn", "twitter", "instagram", "facebook", "tiktok", "github", "threads", "telegram", "bluesky", "mastodon", "reddit", "youtube", "twitch", "snapchat", "pinterest"]
+ if orderedContactFields.contains(where: { socialTypes.contains($0.typeId) }) {
+ return true
+ }
+ // Fallback to legacy properties
+ return !linkedIn.isEmpty || !twitter.isEmpty || !instagram.isEmpty ||
+ !facebook.isEmpty || !tiktok.isEmpty || !github.isEmpty ||
+ !threads.isEmpty || !telegram.isEmpty
+ }
+
+ /// Returns true if the card has any payment links
+ var hasPaymentLinks: Bool {
+ !venmo.isEmpty || !cashApp.isEmpty
+ }
+
+ /// Returns accreditations as an array (stored as comma-separated string)
+ var accreditationsList: [String] {
+ get {
+ accreditations.split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ }
+ set {
+ accreditations = newValue.joined(separator: ", ")
+ }
+ }
+
+ /// Computed display name with special formatting:
+ /// - Preferred name in quotes: "Bubba"
+ /// - Maiden name in parentheses: (Hackney)
+ /// - Pronouns in parentheses: (He/Him)
+ var computedDisplayName: String {
+ if !displayName.isEmpty { return displayName }
+
+ var parts: [String] = []
+
+ // Prefix (Dr, Mr, Ms, etc.)
+ if !prefix.isEmpty {
+ parts.append(prefix)
+ }
+
+ // First name
+ if !firstName.isEmpty {
+ parts.append(firstName)
+ }
+
+ // Preferred name in quotes
+ if !preferredName.isEmpty {
+ parts.append("\"\(preferredName)\"")
+ }
+
+ // Middle name
+ if !middleName.isEmpty {
+ parts.append(middleName)
+ }
+
+ // Last name
+ if !lastName.isEmpty {
+ parts.append(lastName)
+ }
+
+ // Suffix (Jr, III, etc.)
+ if !suffix.isEmpty {
+ parts.append(suffix)
+ }
+
+ // Maiden name in parentheses
+ if !maidenName.isEmpty {
+ parts.append("(\(maidenName))")
+ }
+
+ // Pronouns in parentheses
+ if !pronouns.isEmpty {
+ parts.append("(\(pronouns))")
+ }
+
+ return parts.joined(separator: " ")
+ }
+
+ /// Returns the simple name for display (without formatting) - used for vCard
+ var simpleName: String {
+ if !displayName.isEmpty { return displayName }
+ let parts = [prefix, firstName, middleName, lastName, suffix].filter { !$0.isEmpty }
+ return parts.joined(separator: " ")
+ }
+
+ /// Returns the name to display (preferredName or first/last name)
+ var effectiveDisplayName: String {
+ if !preferredName.isEmpty { return preferredName }
+ let parts = [firstName, lastName].filter { !$0.isEmpty }
+ return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ")
}
/// Returns true if the card has custom links
diff --git a/BusinessCard/Models/Contact.swift b/BusinessCard/Models/Contact.swift
index 9b298ca..39adc55 100644
--- a/BusinessCard/Models/Contact.swift
+++ b/BusinessCard/Models/Contact.swift
@@ -11,6 +11,16 @@ final class Contact {
var lastSharedDate: Date
var cardLabel: String
+ // Enhanced name fields
+ var prefix: String
+ var firstName: String
+ var middleName: String
+ var lastName: String
+ var suffix: String
+ var maidenName: String
+ var preferredName: String
+ var pronouns: String
+
// Contact annotations
var notes: String
var tags: String // Comma-separated tags
@@ -34,6 +44,14 @@ final class Contact {
avatarSystemName: String = "person.crop.circle",
lastSharedDate: Date = .now,
cardLabel: String = "Work",
+ prefix: String = "",
+ firstName: String = "",
+ middleName: String = "",
+ lastName: String = "",
+ suffix: String = "",
+ maidenName: String = "",
+ preferredName: String = "",
+ pronouns: String = "",
notes: String = "",
tags: String = "",
followUpDate: Date? = nil,
@@ -51,6 +69,14 @@ final class Contact {
self.avatarSystemName = avatarSystemName
self.lastSharedDate = lastSharedDate
self.cardLabel = cardLabel
+ self.prefix = prefix
+ self.firstName = firstName
+ self.middleName = middleName
+ self.lastName = lastName
+ self.suffix = suffix
+ self.maidenName = maidenName
+ self.preferredName = preferredName
+ self.pronouns = pronouns
self.notes = notes
self.tags = tags
self.followUpDate = followUpDate
diff --git a/BusinessCard/Models/ContactField.swift b/BusinessCard/Models/ContactField.swift
new file mode 100644
index 0000000..7ff81ca
--- /dev/null
+++ b/BusinessCard/Models/ContactField.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+import SwiftData
+
+/// A single contact field stored with the business card
+/// Supports ordering and multiple fields of the same type
+@Model
+final class ContactField {
+ /// Unique identifier
+ var id: UUID = UUID()
+
+ /// The type ID (matches ContactFieldType.id)
+ var typeId: String = ""
+
+ /// The value (email address, phone number, URL, etc.)
+ var value: String = ""
+
+ /// Optional title/label (e.g., "Work", "Personal", "Connect with me")
+ var title: String = ""
+
+ /// Order index for drag-to-reorder support
+ var orderIndex: Int = 0
+
+ /// Parent business card (inverse relationship)
+ var card: BusinessCard?
+
+ init(typeId: String, value: String = "", title: String = "", orderIndex: Int = 0) {
+ self.id = UUID()
+ self.typeId = typeId
+ self.value = value
+ self.title = title
+ self.orderIndex = orderIndex
+ }
+
+ /// Convenience initializer from ContactFieldType
+ convenience init(fieldType: ContactFieldType, value: String = "", title: String = "", orderIndex: Int = 0) {
+ self.init(typeId: fieldType.id, value: value, title: title, orderIndex: orderIndex)
+ }
+
+ /// Gets the ContactFieldType for this field
+ @MainActor
+ var fieldType: ContactFieldType? {
+ ContactFieldType.allCases.first { $0.id == typeId }
+ }
+
+ /// Display name from the field type
+ @MainActor
+ var displayName: String {
+ fieldType?.displayName ?? typeId
+ }
+
+ /// Icon from the field type
+ @MainActor
+ var systemImage: String {
+ fieldType?.systemImage ?? "link"
+ }
+
+ /// Icon color from the field type
+ @MainActor
+ var iconColor: Color {
+ fieldType?.iconColor ?? Color.gray
+ }
+
+ /// Builds the URL for this field
+ @MainActor
+ func buildURL() -> URL? {
+ fieldType?.buildURL(value: value)
+ }
+}
+
+// MARK: - Conversion helpers
+
+extension ContactField {
+ /// Creates an AddedContactField for use in the editor UI
+ @MainActor
+ func toAddedContactField() -> AddedContactField? {
+ guard let type = fieldType else { return nil }
+ return AddedContactField(id: id, fieldType: type, value: value, title: title)
+ }
+
+ /// Updates from an AddedContactField
+ func update(from addedField: AddedContactField) {
+ self.value = addedField.value
+ self.title = addedField.title
+ }
+}
diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift
new file mode 100644
index 0000000..871f4dc
--- /dev/null
+++ b/BusinessCard/Models/ContactFieldType.swift
@@ -0,0 +1,641 @@
+import SwiftUI
+
+/// Category for grouping contact field types in the picker
+enum ContactFieldCategory: String, CaseIterable, Sendable {
+ case contact
+ case social
+ case developer
+ case messaging
+ case payment
+ case creator
+ case scheduling
+ case other
+
+ var displayName: String {
+ switch self {
+ case .contact: return String(localized: "Contact")
+ case .social: return String(localized: "Social Media")
+ case .developer: return String(localized: "Developer")
+ case .messaging: return String(localized: "Messaging")
+ case .payment: return String(localized: "Payment")
+ case .creator: return String(localized: "Support & Funding")
+ case .scheduling: return String(localized: "Scheduling")
+ case .other: return String(localized: "Other")
+ }
+ }
+}
+
+/// Defines a contact field type with all its configuration
+struct ContactFieldType: Identifiable, Hashable, Sendable {
+ let id: String
+ let displayName: String
+ let systemImage: String
+ let iconColor: Color
+ let category: ContactFieldCategory
+ let valueLabel: String
+ let valuePlaceholder: String
+ let titleSuggestions: [String]
+ let keyboardType: UIKeyboardType
+ let autocapitalization: TextInputAutocapitalization
+ let urlBuilder: @Sendable (String) -> URL?
+
+ init(
+ id: String,
+ displayName: String,
+ systemImage: String,
+ iconColor: Color,
+ category: ContactFieldCategory,
+ valueLabel: String,
+ valuePlaceholder: String,
+ titleSuggestions: [String],
+ keyboardType: UIKeyboardType,
+ autocapitalization: TextInputAutocapitalization = .never,
+ urlBuilder: @escaping @Sendable (String) -> URL?
+ ) {
+ self.id = id
+ self.displayName = displayName
+ self.systemImage = systemImage
+ self.iconColor = iconColor
+ self.category = category
+ self.valueLabel = valueLabel
+ self.valuePlaceholder = valuePlaceholder
+ self.titleSuggestions = titleSuggestions
+ self.keyboardType = keyboardType
+ self.autocapitalization = autocapitalization
+ self.urlBuilder = urlBuilder
+ }
+
+ // MARK: - Hashable & Equatable
+
+ static func == (lhs: ContactFieldType, rhs: ContactFieldType) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+
+ // MARK: - URL Building
+
+ func buildURL(value: String) -> URL? {
+ urlBuilder(value)
+ }
+}
+
+// MARK: - All Field Types
+
+extension ContactFieldType {
+
+ /// All available field types
+ static let allCases: [ContactFieldType] = [
+ // Contact
+ .phone, .email, .website, .address,
+ // Social
+ .linkedIn, .twitter, .instagram, .facebook, .tiktok, .threads,
+ .youtube, .snapchat, .pinterest, .twitch, .bluesky, .mastodon, .reddit,
+ // Developer
+ .github, .gitlab, .stackoverflow,
+ // Messaging
+ .telegram, .whatsapp, .signal, .discord, .slack, .matrix,
+ // Payment
+ .venmo, .cashApp, .paypal, .zelle,
+ // Creator
+ .patreon, .kofi,
+ // Scheduling
+ .calendly,
+ // Other
+ .customLink
+ ]
+
+ /// Field types grouped by category
+ static var byCategory: [ContactFieldCategory: [ContactFieldType]] {
+ Dictionary(grouping: allCases, by: { $0.category })
+ }
+
+ // MARK: - Contact
+
+ static let phone = ContactFieldType(
+ id: "phone",
+ displayName: String(localized: "Phone Number"),
+ systemImage: "phone.fill",
+ iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
+ category: .contact,
+ valueLabel: String(localized: "Phone Number"),
+ valuePlaceholder: "+1 (555) 123-4567",
+ titleSuggestions: [String(localized: "Cell"), String(localized: "Work"), String(localized: "Home")],
+ keyboardType: .phonePad,
+ urlBuilder: { value in
+ let digits = value.filter { $0.isNumber || $0 == "+" }
+ return URL(string: "tel:\(digits)")
+ }
+ )
+
+ static let email = ContactFieldType(
+ id: "email",
+ displayName: String(localized: "Email"),
+ systemImage: "envelope.fill",
+ iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
+ category: .contact,
+ valueLabel: String(localized: "Email"),
+ valuePlaceholder: "you@example.com",
+ titleSuggestions: [String(localized: "Work"), String(localized: "Personal")],
+ keyboardType: .emailAddress,
+ urlBuilder: { URL(string: "mailto:\($0)") }
+ )
+
+ static let website = ContactFieldType(
+ id: "website",
+ displayName: String(localized: "Company Website"),
+ systemImage: "globe",
+ iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
+ category: .contact,
+ valueLabel: String(localized: "Website URL"),
+ valuePlaceholder: "https://company.com",
+ titleSuggestions: [String(localized: "Company Website"), String(localized: "Portfolio")],
+ keyboardType: .URL,
+ urlBuilder: { buildWebURL($0) }
+ )
+
+ static let address = ContactFieldType(
+ id: "address",
+ displayName: String(localized: "Address"),
+ systemImage: "location.fill",
+ iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
+ category: .contact,
+ valueLabel: String(localized: "Address"),
+ valuePlaceholder: "123 Main St, City, State",
+ titleSuggestions: [String(localized: "Work"), String(localized: "Home")],
+ keyboardType: .default,
+ urlBuilder: { value in
+ let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
+ return URL(string: "maps://?q=\(encoded)")
+ }
+ )
+
+ // MARK: - Social Media
+
+ static let linkedIn = ContactFieldType(
+ id: "linkedIn",
+ displayName: "LinkedIn",
+ systemImage: "linkedin",
+ iconColor: Color(red: 0.0, green: 0.47, blue: 0.71),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "linkedin.com/in/username",
+ titleSuggestions: ["Connect with me on LinkedIn"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "linkedin.com") }
+ )
+
+ static let twitter = ContactFieldType(
+ id: "twitter",
+ displayName: "X",
+ systemImage: "x-twitter",
+ iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "x.com/username",
+ titleSuggestions: ["Follow me on X"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "x.com") }
+ )
+
+ static let instagram = ContactFieldType(
+ id: "instagram",
+ displayName: "Instagram",
+ systemImage: "instagram",
+ iconColor: Color(red: 0.88, green: 0.19, blue: 0.42),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "instagram.com/username",
+ titleSuggestions: ["Follow me on Instagram"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "instagram.com") }
+ )
+
+ static let facebook = ContactFieldType(
+ id: "facebook",
+ displayName: "Facebook",
+ systemImage: "facebook",
+ iconColor: Color(red: 0.26, green: 0.40, blue: 0.70),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "facebook.com/username",
+ titleSuggestions: ["Connect on Facebook"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "facebook.com") }
+ )
+
+ static let tiktok = ContactFieldType(
+ id: "tiktok",
+ displayName: "TikTok",
+ systemImage: "tiktok",
+ iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "tiktok.com/@username",
+ titleSuggestions: ["Follow me on TikTok"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "tiktok.com") }
+ )
+
+ static let threads = ContactFieldType(
+ id: "threads",
+ displayName: "Threads",
+ systemImage: "threads",
+ iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "threads.net/@username",
+ titleSuggestions: ["Follow me on Threads"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "threads.net") }
+ )
+
+ static let youtube = ContactFieldType(
+ id: "youtube",
+ displayName: "YouTube",
+ systemImage: "youtube.fill",
+ iconColor: Color(red: 1.0, green: 0.0, blue: 0.0),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "youtube.com/@channel",
+ titleSuggestions: ["Subscribe to my channel"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "youtube.com") }
+ )
+
+ static let snapchat = ContactFieldType(
+ id: "snapchat",
+ displayName: "Snapchat",
+ systemImage: "camera.fill",
+ iconColor: Color(red: 1.0, green: 0.98, blue: 0.0),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "snapchat.com/add/username",
+ titleSuggestions: ["Add me on Snapchat"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "snapchat.com") }
+ )
+
+ static let pinterest = ContactFieldType(
+ id: "pinterest",
+ displayName: "Pinterest",
+ systemImage: "pin.fill",
+ iconColor: Color(red: 0.9, green: 0.11, blue: 0.14),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "pinterest.com/username",
+ titleSuggestions: ["Follow me on Pinterest"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "pinterest.com") }
+ )
+
+ static let twitch = ContactFieldType(
+ id: "twitch",
+ displayName: "Twitch",
+ systemImage: "twitch",
+ iconColor: Color(red: 0.57, green: 0.27, blue: 1.0),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "twitch.tv/username",
+ titleSuggestions: ["Watch me on Twitch"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "twitch.tv") }
+ )
+
+ static let bluesky = ContactFieldType(
+ id: "bluesky",
+ displayName: "Bluesky",
+ systemImage: "bluesky",
+ iconColor: Color(red: 0.0, green: 0.52, blue: 1.0),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "bsky.app/profile/username",
+ titleSuggestions: ["Follow me on Bluesky"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "bsky.app") }
+ )
+
+ static let mastodon = ContactFieldType(
+ id: "mastodon",
+ displayName: "Mastodon",
+ systemImage: "mastodon.fill",
+ iconColor: Color(red: 0.38, green: 0.28, blue: 0.85),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "mastodon.social/@username",
+ titleSuggestions: ["Follow me on Mastodon"],
+ keyboardType: .URL,
+ urlBuilder: { value in
+ if value.hasPrefix("http://") || value.hasPrefix("https://") {
+ return URL(string: value)
+ }
+ if value.contains(".") {
+ return URL(string: "https://\(value)")
+ }
+ let username = value.hasPrefix("@") ? String(value.dropFirst()) : value
+ return URL(string: "https://mastodon.social/@\(username)")
+ }
+ )
+
+ static let reddit = ContactFieldType(
+ id: "reddit",
+ displayName: "Reddit",
+ systemImage: "reddit.fill",
+ iconColor: Color(red: 1.0, green: 0.27, blue: 0.0),
+ category: .social,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "reddit.com/user/username",
+ titleSuggestions: ["Follow me on Reddit"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "reddit.com") }
+ )
+
+ // MARK: - Developer
+
+ static let github = ContactFieldType(
+ id: "github",
+ displayName: "GitHub",
+ systemImage: "github.fill",
+ iconColor: Color(red: 0.13, green: 0.13, blue: 0.13),
+ category: .developer,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "github.com/username",
+ titleSuggestions: ["View our work on GitHub", "View our GitHub Repo"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "github.com") }
+ )
+
+ static let gitlab = ContactFieldType(
+ id: "gitlab",
+ displayName: "GitLab",
+ systemImage: "chevron.left.forwardslash.chevron.right",
+ iconColor: Color(red: 0.99, green: 0.41, blue: 0.13),
+ category: .developer,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "gitlab.com/username",
+ titleSuggestions: ["View our work on GitLab"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "gitlab.com") }
+ )
+
+ static let stackoverflow = ContactFieldType(
+ id: "stackoverflow",
+ displayName: "Stack Overflow",
+ systemImage: "text.bubble.fill",
+ iconColor: Color(red: 0.95, green: 0.51, blue: 0.13),
+ category: .developer,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "stackoverflow.com/users/id",
+ titleSuggestions: ["Ask me on Stack Overflow"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "stackoverflow.com") }
+ )
+
+ // MARK: - Messaging
+
+ static let telegram = ContactFieldType(
+ id: "telegram",
+ displayName: "Telegram",
+ systemImage: "telegram",
+ iconColor: Color(red: 0.16, green: 0.63, blue: 0.89),
+ category: .messaging,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "t.me/username",
+ titleSuggestions: ["Connect with me on Telegram"],
+ keyboardType: .URL,
+ urlBuilder: { value in
+ if value.hasPrefix("t.me/") || value.hasPrefix("https://t.me/") {
+ return URL(string: value.hasPrefix("https://") ? value : "https://\(value)")
+ }
+ return URL(string: "tg://resolve?domain=\(value)")
+ }
+ )
+
+ static let whatsapp = ContactFieldType(
+ id: "whatsapp",
+ displayName: "WhatsApp",
+ systemImage: "message.fill",
+ iconColor: Color(red: 0.15, green: 0.68, blue: 0.38),
+ category: .messaging,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "+1 555 123 4567",
+ titleSuggestions: ["Message me on WhatsApp"],
+ keyboardType: .phonePad,
+ urlBuilder: { value in
+ let digits = value.filter { $0.isNumber }
+ return URL(string: "https://wa.me/\(digits)")
+ }
+ )
+
+ static let signal = ContactFieldType(
+ id: "signal",
+ displayName: "Signal",
+ systemImage: "bubble.left.fill",
+ iconColor: Color(red: 0.23, green: 0.47, blue: 0.98),
+ category: .messaging,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "+1 555 123 4567",
+ titleSuggestions: ["Message me on Signal"],
+ keyboardType: .phonePad,
+ urlBuilder: { value in
+ let digits = value.filter { $0.isNumber || $0 == "+" }
+ return URL(string: "sgnl://signal.me/#p/\(digits)")
+ }
+ )
+
+ static let discord = ContactFieldType(
+ id: "discord",
+ displayName: "Discord",
+ systemImage: "discord.fill",
+ iconColor: Color(red: 0.34, green: 0.40, blue: 0.95),
+ category: .messaging,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "discord.gg/invite",
+ titleSuggestions: ["Join my Discord"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "discord.gg") }
+ )
+
+ static let slack = ContactFieldType(
+ id: "slack",
+ displayName: "Slack",
+ systemImage: "slack",
+ iconColor: Color(red: 0.38, green: 0.11, blue: 0.44),
+ category: .messaging,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "yourworkspace.slack.com",
+ titleSuggestions: ["Join our Slack"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "slack.com") }
+ )
+
+ static let matrix = ContactFieldType(
+ id: "matrix",
+ displayName: "Matrix",
+ systemImage: "matrix",
+ iconColor: Color(red: 0.0, green: 0.73, blue: 0.58),
+ category: .messaging,
+ valueLabel: String(localized: "Username/Link"),
+ valuePlaceholder: "@username:matrix.org",
+ titleSuggestions: ["Chat with me on Matrix"],
+ keyboardType: .URL,
+ urlBuilder: { value in
+ if value.contains("matrix.to") || value.contains("element.io") {
+ return URL(string: value.hasPrefix("https://") ? value : "https://\(value)")
+ }
+ let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
+ return URL(string: "https://matrix.to/#/\(encoded)")
+ }
+ )
+
+ // MARK: - Payment
+
+ static let venmo = ContactFieldType(
+ id: "venmo",
+ displayName: "Venmo",
+ systemImage: "dollarsign.circle.fill",
+ iconColor: Color(red: 0.22, green: 0.53, blue: 0.79),
+ category: .payment,
+ valueLabel: String(localized: "Username"),
+ valuePlaceholder: "@username",
+ titleSuggestions: ["Pay via Venmo"],
+ keyboardType: .default,
+ urlBuilder: { value in
+ let username = value.hasPrefix("@") ? String(value.dropFirst()) : value
+ return URL(string: "venmo://users/\(username)")
+ }
+ )
+
+ static let cashApp = ContactFieldType(
+ id: "cashApp",
+ displayName: "Cash App",
+ systemImage: "dollarsign.square.fill",
+ iconColor: Color(red: 0.0, green: 0.82, blue: 0.35),
+ category: .payment,
+ valueLabel: String(localized: "Username"),
+ valuePlaceholder: "$cashtag",
+ titleSuggestions: ["Pay via Cash App"],
+ keyboardType: .default,
+ urlBuilder: { value in
+ let cashtag = value.hasPrefix("$") ? String(value.dropFirst()) : value
+ return URL(string: "cashapp://cash.app/$\(cashtag)")
+ }
+ )
+
+ static let paypal = ContactFieldType(
+ id: "paypal",
+ displayName: "PayPal",
+ systemImage: "creditcard.fill",
+ iconColor: Color(red: 0.0, green: 0.19, blue: 0.56),
+ category: .payment,
+ valueLabel: String(localized: "Email or Username"),
+ valuePlaceholder: "paypal.me/username",
+ titleSuggestions: ["Pay via PayPal"],
+ keyboardType: .emailAddress,
+ urlBuilder: { URL(string: "https://paypal.me/\($0)") }
+ )
+
+ static let zelle = ContactFieldType(
+ id: "zelle",
+ displayName: "Zelle",
+ systemImage: "dollarsign.arrow.circlepath",
+ iconColor: Color(red: 0.42, green: 0.11, blue: 0.69),
+ category: .payment,
+ valueLabel: String(localized: "Phone or Email"),
+ valuePlaceholder: "email@example.com",
+ titleSuggestions: ["Pay via Zelle"],
+ keyboardType: .phonePad,
+ urlBuilder: { _ in nil } // Zelle has no universal deep link
+ )
+
+ // MARK: - Creator/Funding
+
+ static let patreon = ContactFieldType(
+ id: "patreon",
+ displayName: "Patreon",
+ systemImage: "patreon.fill",
+ iconColor: Color(red: 1.0, green: 0.27, blue: 0.33),
+ category: .creator,
+ valueLabel: String(localized: "Profile Link"),
+ valuePlaceholder: "patreon.com/username",
+ titleSuggestions: ["Support me on Patreon"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "patreon.com") }
+ )
+
+ static let kofi = ContactFieldType(
+ id: "kofi",
+ displayName: "Ko-fi",
+ systemImage: "ko-fi",
+ iconColor: Color(red: 1.0, green: 0.35, blue: 0.45),
+ category: .creator,
+ valueLabel: String(localized: "Profile Link"),
+ valuePlaceholder: "ko-fi.com/username",
+ titleSuggestions: ["Buy me a coffee"],
+ keyboardType: .URL,
+ urlBuilder: { buildSocialURL($0, webBase: "ko-fi.com") }
+ )
+
+ // MARK: - Scheduling
+
+ static let calendly = ContactFieldType(
+ id: "calendly",
+ displayName: "Calendly",
+ systemImage: "calendar",
+ iconColor: Color(red: 0.0, green: 0.42, blue: 0.95),
+ category: .scheduling,
+ valueLabel: String(localized: "Calendly Link"),
+ valuePlaceholder: "calendly.com/username",
+ titleSuggestions: ["Schedule a meeting"],
+ keyboardType: .URL,
+ urlBuilder: { buildWebURL($0) }
+ )
+
+ // MARK: - Other
+
+ static let customLink = ContactFieldType(
+ id: "customLink",
+ displayName: String(localized: "Link"),
+ systemImage: "link",
+ iconColor: Color(red: 0.2, green: 0.2, blue: 0.2),
+ category: .other,
+ valueLabel: String(localized: "URL"),
+ valuePlaceholder: "https://example.com",
+ titleSuggestions: [],
+ keyboardType: .URL,
+ urlBuilder: { buildWebURL($0) }
+ )
+}
+
+// MARK: - URL Helper Functions
+
+nonisolated private func buildWebURL(_ value: String) -> URL? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
+ return URL(string: trimmed)
+ }
+ return URL(string: "https://\(trimmed)")
+}
+
+nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ // If already a full URL, use it
+ if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
+ return URL(string: trimmed)
+ }
+
+ // If contains the base domain, add https
+ if trimmed.contains(webBase) {
+ return URL(string: "https://\(trimmed)")
+ }
+
+ // Otherwise, treat as username and build URL
+ let username = trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
+ return URL(string: "https://\(webBase)/\(username)")
+}
diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings
index 2bfb5bc..38cd6a7 100644
--- a/BusinessCard/Resources/Localizable.xcstrings
+++ b/BusinessCard/Resources/Localizable.xcstrings
@@ -36,6 +36,15 @@
},
"+1 555 123 4567" : {
+ },
+ "About" : {
+
+ },
+ "Accreditations" : {
+
+ },
+ "Add %@" : {
+
},
"Add a QR widget so your card is always one tap away." : {
"localizations" : {
@@ -58,6 +67,9 @@
}
}
}
+ },
+ "Address" : {
+
},
"Are you sure you want to delete this card? This action cannot be undone." : {
"comment" : "An alert message displayed when the user attempts to delete a card. It confirms the action and warns that it cannot be undone.",
@@ -65,9 +77,18 @@
},
"Are you sure you want to delete this contact?" : {
+ },
+ "Bio" : {
+
+ },
+ "Calendly Link" : {
+
},
"Card Found!" : {
+ },
+ "Card Label" : {
+
},
"Card style" : {
"localizations" : {
@@ -90,6 +111,9 @@
}
}
}
+ },
+ "Cell" : {
+
},
"Change image layout" : {
"localizations" : {
@@ -112,6 +136,18 @@
}
}
}
+ },
+ "Choose a card in the My Cards tab to start sharing." : {
+
+ },
+ "Company Website" : {
+
+ },
+ "Contact" : {
+
+ },
+ "Cover photo" : {
+
},
"Create multiple business cards" : {
"localizations" : {
@@ -134,6 +170,9 @@
}
}
}
+ },
+ "Custom Links" : {
+
},
"Customize your card" : {
"localizations" : {
@@ -156,9 +195,42 @@
}
}
}
+ },
+ "Delete" : {
+
+ },
+ "Delete Field" : {
+
+ },
+ "Developer" : {
+
+ },
+ "e.g. MBA, CPA, PhD" : {
+
+ },
+ "e.g. Work, Personal" : {
+
+ },
+ "Edit %@" : {
+
+ },
+ "Email" : {
+
+ },
+ "Email or Username" : {
+
},
"Example" : {
+ },
+ "Ext." : {
+
+ },
+ "Here are some suggestions for your title:" : {
+
+ },
+ "Hold each field below to re-order it" : {
+
},
"Hold your phone near another device to share instantly. NFC setup is on the way." : {
"localizations" : {
@@ -182,7 +254,7 @@
}
}
},
- "Icon (if no photo)" : {
+ "Home" : {
},
"Images & layout" : {
@@ -206,6 +278,21 @@
}
}
}
+ },
+ "Label" : {
+
+ },
+ "Link" : {
+
+ },
+ "Location" : {
+
+ },
+ "Messaging" : {
+
+ },
+ "No card selected" : {
+
},
"No contacts yet" : {
@@ -231,6 +318,27 @@
}
}
}
+ },
+ "Other" : {
+
+ },
+ "Payment" : {
+
+ },
+ "Personal" : {
+
+ },
+ "Personal details" : {
+
+ },
+ "Phone" : {
+
+ },
+ "Phone Number" : {
+
+ },
+ "Phone or Email" : {
+
},
"Phone Widget" : {
"localizations" : {
@@ -281,6 +389,15 @@
}
}
}
+ },
+ "Portfolio" : {
+
+ },
+ "Preview card" : {
+
+ },
+ "Profile Link" : {
+
},
"QR Code Scanned" : {
@@ -306,6 +423,21 @@
}
}
}
+ },
+ "Record who received your card" : {
+
+ },
+ "Removes this field" : {
+
+ },
+ "Save" : {
+
+ },
+ "Scheduling" : {
+
+ },
+ "Share card offline" : {
+
},
"Share using widgets on your phone or watch" : {
"localizations" : {
@@ -330,6 +462,7 @@
}
},
"Share with anyone" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -356,9 +489,21 @@
},
"Shared With" : {
+ },
+ "Social Media" : {
+
+ },
+ "Support & Funding" : {
+
},
"Tap \"New Card\" to create your first card" : {
+ },
+ "Tap a field below to add it" : {
+
+ },
+ "Tap to edit this accreditation" : {
+
},
"Tap to share" : {
"localizations" : {
@@ -381,6 +526,9 @@
}
}
}
+ },
+ "Tell people about yourself..." : {
+
},
"The #1 Digital Business Card App" : {
"localizations" : {
@@ -409,6 +557,15 @@
},
"This person will appear in your Contacts tab so you can track who has your card." : {
+ },
+ "Title (optional)" : {
+
+ },
+ "Track this share" : {
+
+ },
+ "URL" : {
+
},
"Used by Industry Leaders" : {
"localizations" : {
@@ -431,6 +588,12 @@
}
}
}
+ },
+ "Username" : {
+
+ },
+ "Username/Link" : {
+
},
"Wallet export is coming soon. We'll let you know as soon as it's ready." : {
"localizations" : {
@@ -475,6 +638,15 @@
}
}
}
+ },
+ "Website" : {
+
+ },
+ "Website URL" : {
+
+ },
+ "Work" : {
+
},
"Your card will appear here" : {
diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift
index 566cab9..3b6b12a 100644
--- a/BusinessCard/Views/BusinessCardView.swift
+++ b/BusinessCard/Views/BusinessCardView.swift
@@ -4,27 +4,19 @@ import SwiftData
struct BusinessCardView: View {
let card: BusinessCard
+ var isCompact: Bool = false
var body: some View {
- VStack(spacing: Design.Spacing.medium) {
- switch card.layoutStyle {
- case .stacked:
- StackedCardLayout(card: card)
- case .split:
- SplitCardLayout(card: card)
- case .photo:
- PhotoCardLayout(card: card)
- }
+ VStack(spacing: 0) {
+ // Banner with logo
+ CardBannerView(card: card)
+
+ // Content area with avatar overlapping
+ CardContentView(card: card, isCompact: isCompact)
+ .offset(y: -Design.CardSize.avatarOverlap)
+ .padding(.bottom, -Design.CardSize.avatarOverlap)
}
- .padding(Design.Spacing.large)
- .frame(maxWidth: .infinity)
- .background(
- LinearGradient(
- colors: [card.theme.primaryColor, card.theme.secondaryColor],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
+ .background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
@@ -34,217 +26,282 @@ struct BusinessCardView: View {
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String.localized("Business card"))
- .accessibilityValue("\(card.displayName), \(card.role), \(card.company)")
+ .accessibilityValue("\(card.effectiveDisplayName), \(card.role), \(card.company)")
}
}
-// MARK: - Layout Variants
+// MARK: - Banner View
-private struct StackedCardLayout: View {
- let card: BusinessCard
-
- var body: some View {
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- CardHeaderView(card: card)
- Divider()
- .overlay(card.theme.textColor.opacity(Design.Opacity.medium))
- CardDetailsView(card: card)
- if card.hasSocialLinks {
- SocialLinksRow(card: card)
- }
- }
- }
-}
-
-private struct SplitCardLayout: View {
- let card: BusinessCard
-
- var body: some View {
- HStack(spacing: Design.Spacing.large) {
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- CardHeaderView(card: card)
- CardDetailsView(card: card)
- if card.hasSocialLinks {
- SocialLinksRow(card: card)
- }
- }
- Spacer(minLength: Design.Spacing.medium)
- AccentBlockView(color: card.theme.accentColor, textColor: card.theme.textColor)
- }
- }
-}
-
-private struct PhotoCardLayout: View {
- let card: BusinessCard
-
- var body: some View {
- HStack(spacing: Design.Spacing.large) {
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- CardHeaderView(card: card)
- CardDetailsView(card: card)
- if card.hasSocialLinks {
- SocialLinksRow(card: card)
- }
- }
- Spacer(minLength: Design.Spacing.medium)
- AvatarBadgeView(
- systemName: card.avatarSystemName,
- accentColor: card.theme.accentColor,
- photoData: card.photoData,
- borderColor: card.theme.textColor
- )
- }
- }
-}
-
-// MARK: - Card Sections
-
-private struct CardHeaderView: View {
+private struct CardBannerView: View {
let card: BusinessCard
- private var textColor: Color { card.theme.textColor }
-
var body: some View {
- HStack(spacing: Design.Spacing.medium) {
- AvatarBadgeView(
- systemName: card.avatarSystemName,
- accentColor: card.theme.accentColor,
- photoData: card.photoData,
- borderColor: textColor
+ ZStack {
+ // Gradient background
+ LinearGradient(
+ colors: [card.theme.primaryColor, card.theme.secondaryColor],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
)
+
+ // Company logo
+ if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFit()
+ .frame(height: Design.CardSize.logoSize)
+ } else if !card.company.isEmpty {
+ Text(card.company.prefix(1).uppercased())
+ .font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded))
+ .foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
+ }
+ }
+ .frame(height: Design.CardSize.bannerHeight)
+ }
+}
+
+// MARK: - Content View
+
+private struct CardContentView: View {
+ let card: BusinessCard
+ let isCompact: Bool
+
+ private var textColor: Color { Color.Text.primary }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ // Avatar and label row
+ HStack(alignment: .bottom) {
+ ProfileAvatarView(card: card)
+ Spacer()
+ LabelBadgeView(label: card.label, accentColor: card.theme.accentColor, textColor: card.theme.textColor)
+ .padding(.bottom, Design.CardSize.avatarOverlap)
+ }
+
+ // Name and title
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
- Text(card.displayName)
- .font(.headline)
+ Text(card.effectiveDisplayName)
+ .font(.title2)
.bold()
.foregroundStyle(textColor)
if !card.pronouns.isEmpty {
Text("(\(card.pronouns))")
- .font(.caption)
- .foregroundStyle(textColor.opacity(Design.Opacity.strong))
+ .font(.subheadline)
+ .foregroundStyle(Color.Text.secondary)
}
}
+
Text(card.role)
- .font(.subheadline)
- .foregroundStyle(textColor.opacity(Design.Opacity.almostFull))
+ .font(.headline)
+ .foregroundStyle(textColor)
+
Text(card.company)
- .font(.caption)
- .foregroundStyle(textColor.opacity(Design.Opacity.medium))
+ .font(.subheadline)
+ .foregroundStyle(Color.Text.secondary)
+
+ if !card.headline.isEmpty {
+ Text(card.headline)
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ .padding(.top, Design.Spacing.xxSmall)
+ }
+ }
+
+ if !isCompact {
+ Divider()
+ .padding(.vertical, Design.Spacing.xSmall)
+
+ // Contact details
+ ContactDetailsView(card: card)
+
+ // Social links
+ if card.hasSocialLinks {
+ SocialLinksRow(card: card)
+ .padding(.top, Design.Spacing.xSmall)
+ }
}
- Spacer(minLength: Design.Spacing.small)
- LabelBadgeView(label: card.label, accentColor: card.theme.accentColor, textColor: textColor)
}
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.bottom, Design.Spacing.large)
}
}
-private struct CardDetailsView: View {
+// MARK: - Profile Avatar
+
+private struct ProfileAvatarView: View {
let card: BusinessCard
- private var textColor: Color { card.theme.textColor }
+ var body: some View {
+ Group {
+ if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ } else {
+ Image(systemName: card.avatarSystemName)
+ .font(.system(size: Design.BaseFontSize.title))
+ .foregroundStyle(card.theme.textColor)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(card.theme.accentColor)
+ }
+ }
+ .frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
+ .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: - Contact Details
+
+private struct ContactDetailsView: View {
+ let card: BusinessCard
var body: some View {
- VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
if !card.email.isEmpty {
- IconRowView(systemImage: "envelope", text: card.email, textColor: textColor)
+ ContactRowView(
+ systemImage: "envelope.fill",
+ text: card.email,
+ label: card.emailLabel
+ )
}
if !card.phone.isEmpty {
- IconRowView(systemImage: "phone", text: card.phone, textColor: textColor)
+ ContactRowView(
+ systemImage: "phone.fill",
+ text: card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)",
+ label: card.phoneLabel
+ )
}
if !card.website.isEmpty {
- IconRowView(systemImage: "link", text: card.website, textColor: textColor)
+ ContactRowView(
+ systemImage: "link",
+ text: card.website,
+ label: nil
+ )
}
- if !card.bio.isEmpty {
- Text(card.bio)
- .font(.caption)
- .foregroundStyle(textColor.opacity(Design.Opacity.strong))
- .lineLimit(2)
- .padding(.top, Design.Spacing.xxSmall)
+ if !card.location.isEmpty {
+ ContactRowView(
+ systemImage: "location.fill",
+ text: card.location,
+ label: nil
+ )
}
}
}
}
+private struct ContactRowView: View {
+ let systemImage: String
+ let text: String
+ let label: String?
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.medium) {
+ Image(systemName: systemImage)
+ .font(.body)
+ .foregroundStyle(Color.Accent.red)
+ .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
+ .background(Color.AppBackground.accent)
+ .clipShape(.circle)
+
+ VStack(alignment: .leading, spacing: 0) {
+ Text(text)
+ .font(.subheadline)
+ .foregroundStyle(Color.Text.primary)
+
+ if let label, !label.isEmpty {
+ Text(label)
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Social Links
+
private struct SocialLinksRow: View {
let card: BusinessCard
- private var textColor: Color { card.theme.textColor }
-
var body: some View {
- HStack(spacing: Design.Spacing.small) {
- if !card.linkedIn.isEmpty {
- SocialIconView(systemImage: "link", textColor: textColor)
- }
- if !card.twitter.isEmpty {
- SocialIconView(systemImage: "at", textColor: textColor)
- }
- if !card.instagram.isEmpty {
- SocialIconView(systemImage: "camera", textColor: textColor)
- }
- if !card.facebook.isEmpty {
- SocialIconView(systemImage: "person.2", textColor: textColor)
- }
- if !card.tiktok.isEmpty {
- SocialIconView(systemImage: "play.rectangle", textColor: textColor)
- }
- if !card.github.isEmpty {
- SocialIconView(systemImage: "chevron.left.forwardslash.chevron.right", textColor: textColor)
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: Design.Spacing.small) {
+ if !card.linkedIn.isEmpty {
+ SocialIconButton(name: "LinkedIn", color: Color.Social.linkedIn)
+ }
+ if !card.twitter.isEmpty {
+ SocialIconButton(name: "X", color: Color.Social.twitter)
+ }
+ if !card.instagram.isEmpty {
+ SocialIconButton(name: "IG", color: Color.Social.instagram)
+ }
+ if !card.facebook.isEmpty {
+ SocialIconButton(name: "FB", color: Color.Social.facebook)
+ }
+ if !card.tiktok.isEmpty {
+ SocialIconButton(name: "TT", color: Color.Social.tiktok)
+ }
+ if !card.github.isEmpty {
+ SocialIconButton(name: "GH", color: Color.Social.github)
+ }
+ if !card.threads.isEmpty {
+ SocialIconButton(name: "TH", color: Color.Social.threads)
+ }
+ if !card.telegram.isEmpty {
+ SocialIconButton(name: "TG", color: Color.Social.telegram)
+ }
}
}
- .padding(.top, Design.Spacing.xxSmall)
}
}
-// MARK: - Small Components
-
-private struct SocialIconView: View {
- let systemImage: String
- var textColor: Color = Color.Text.inverted
+private struct SocialIconButton: View {
+ let name: String
+ let color: Color
var body: some View {
- Image(systemName: systemImage)
+ Text(name)
.font(.caption2)
- .foregroundStyle(textColor.opacity(Design.Opacity.strong))
- .frame(width: Design.Spacing.xLarge, height: Design.Spacing.xLarge)
- .background(textColor.opacity(Design.Opacity.hint))
+ .bold()
+ .foregroundStyle(.white)
+ .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
+ .background(color)
.clipShape(.circle)
}
}
-private struct AccentBlockView: View {
- let color: Color
- var textColor: Color = Color.Text.inverted
-
- var body: some View {
- RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
- .fill(color)
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .overlay(
- Image(systemName: "bolt.fill")
- .foregroundStyle(textColor)
- )
- }
-}
-
// MARK: - Preview
#Preview {
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
let context = container.mainContext
let card = BusinessCard(
- displayName: "Daniel Sullivan",
- role: "Property Developer",
- company: "WR Construction",
- email: "daniel@example.com",
- phone: "+1 555 123 4567",
- website: "example.com",
+ displayName: "Matt Bruce",
+ role: "Lead iOS Developer",
+ company: "Toyota",
+ email: "matt.bruce@toyota.com",
+ emailLabel: "Work",
+ phone: "+1 (214) 755-1043",
+ phoneLabel: "Cell",
+ website: "toyota.com",
location: "Dallas, TX",
themeName: "Coral",
- layoutStyleRawValue: "split",
- pronouns: "he/him",
- bio: "Building the future of Dallas real estate",
- linkedIn: "linkedin.com/in/daniel",
- twitter: "twitter.com/daniel"
+ layoutStyleRawValue: "stacked",
+ headline: "Building the future of mobility",
+ linkedIn: "linkedin.com/in/mattbruce",
+ twitter: "twitter.com/mattbruce"
)
context.insert(card)
diff --git a/BusinessCard/Views/CardCarouselView.swift b/BusinessCard/Views/CardCarouselView.swift
index bcb27d9..a86da26 100644
--- a/BusinessCard/Views/CardCarouselView.swift
+++ b/BusinessCard/Views/CardCarouselView.swift
@@ -21,13 +21,13 @@ struct CardCarouselView: View {
if hasCards {
TabView(selection: $cardStore.selectedCardID) {
ForEach(cardStore.cards) { card in
- BusinessCardView(card: card)
+ BusinessCardView(card: card, isCompact: true)
.tag(Optional(card.id))
.padding(.vertical, Design.Spacing.medium)
}
}
.tabViewStyle(.page)
- .frame(height: Design.CardSize.cardHeight + Design.Spacing.xxLarge)
+ .frame(height: Design.CardSize.cardHeightCompact + Design.Spacing.xxLarge)
if let selected = cardStore.selectedCard {
CardDefaultToggleView(card: selected) {
diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift
index 9389852..2984f19 100644
--- a/BusinessCard/Views/CardEditorView.swift
+++ b/BusinessCard/Views/CardEditorView.swift
@@ -10,17 +10,33 @@ struct CardEditorView: View {
let card: BusinessCard?
let onSave: (BusinessCard) -> Void
- // Basic info
+ // Name fields
@State private var displayName = ""
- @State private var role = ""
- @State private var company = ""
- @State private var label = "Work"
+ @State private var prefix = ""
+ @State private var firstName = ""
+ @State private var middleName = ""
+ @State private var lastName = ""
+ @State private var suffix = ""
+ @State private var maidenName = ""
+ @State private var preferredName = ""
@State private var pronouns = ""
+ @State private var showNameDetails = false
+
+ // Professional info
+ @State private var role = ""
+ @State private var department = ""
+ @State private var company = ""
+ @State private var headline = ""
+ @State private var label = "Work"
@State private var bio = ""
+ @State private var accreditations = ""
// Contact details
@State private var email = ""
+ @State private var emailLabel = "Work"
@State private var phone = ""
+ @State private var phoneLabel = "Cell"
+ @State private var phoneExtension = ""
@State private var website = ""
@State private var location = ""
@@ -31,6 +47,10 @@ struct CardEditorView: View {
@State private var facebook = ""
@State private var tiktok = ""
@State private var github = ""
+ @State private var threads = ""
+ @State private var telegram = ""
+ @State private var venmo = ""
+ @State private var cashApp = ""
// Custom links
@State private var customLink1Title = ""
@@ -38,30 +58,104 @@ struct CardEditorView: View {
@State private var customLink2Title = ""
@State private var customLink2URL = ""
+ // Contact fields (unified list for picker-based UI)
+ @State private var contactFields: [AddedContactField] = []
+
// Appearance
@State private var avatarSystemName = "person.crop.circle"
@State private var selectedTheme: CardTheme = .coral
@State private var selectedLayout: CardLayoutStyle = .stacked
- // Photo
+ // Photos
@State private var selectedPhoto: PhotosPickerItem?
@State private var photoData: Data?
+ @State private var selectedLogo: PhotosPickerItem?
+ @State private var logoData: Data?
+
+ @State private var showingPreview = false
private var isEditing: Bool { card != nil }
private var isFormValid: Bool {
- !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ !effectiveDisplayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ /// Simple name for validation and storage (without quotes/parentheses formatting)
+ private var effectiveDisplayName: String {
+ if !displayName.isEmpty { return displayName }
+ let parts = [prefix, firstName, middleName, lastName, suffix].filter { !$0.isEmpty }
+ return parts.joined(separator: " ")
+ }
+
+ /// Formatted name for display with special formatting
+ private var formattedDisplayName: String {
+ if !displayName.isEmpty { return displayName }
+
+ var parts: [String] = []
+
+ if !prefix.isEmpty { parts.append(prefix) }
+ if !firstName.isEmpty { parts.append(firstName) }
+ if !preferredName.isEmpty { parts.append("\"\(preferredName)\"") }
+ if !middleName.isEmpty { parts.append(middleName) }
+ if !lastName.isEmpty { parts.append(lastName) }
+ if !suffix.isEmpty { parts.append(suffix) }
+ if !maidenName.isEmpty { parts.append("(\(maidenName))") }
+ if !pronouns.isEmpty { parts.append("(\(pronouns))") }
+
+ return parts.joined(separator: " ")
}
var body: some View {
NavigationStack {
- Form {
- previewSection
- photoSection
- personalSection
- contactSection
- socialSection
- customLinksSection
- appearanceSection
+ ScrollView {
+ VStack(spacing: 0) {
+ // Card Style section
+ CardStyleSection(selectedTheme: $selectedTheme)
+
+ // Images & Layout section
+ ImagesLayoutSection(
+ selectedPhoto: $selectedPhoto,
+ photoData: $photoData,
+ selectedLogo: $selectedLogo,
+ logoData: $logoData,
+ avatarSystemName: avatarSystemName,
+ selectedTheme: selectedTheme
+ )
+
+ // Personal details section
+ PersonalDetailsSection(
+ displayName: $displayName,
+ prefix: $prefix,
+ firstName: $firstName,
+ middleName: $middleName,
+ lastName: $lastName,
+ suffix: $suffix,
+ maidenName: $maidenName,
+ preferredName: $preferredName,
+ pronouns: $pronouns,
+ showNameDetails: $showNameDetails
+ )
+
+ // Professional section
+ ProfessionalSection(
+ role: $role,
+ department: $department,
+ company: $company,
+ headline: $headline,
+ accreditations: $accreditations,
+ label: $label
+ )
+
+ // Contact & social fields manager
+ ContactFieldsManagerView(fields: $contactFields)
+
+ // Bio section
+ BioSection(bio: $bio)
+ }
+ .padding(.bottom, Design.Spacing.xxLarge * 2)
+ }
+ .background(Color.AppBackground.base)
+ .safeAreaInset(edge: .bottom) {
+ PreviewCardButton { showingPreview = true }
}
.navigationTitle(isEditing ? String.localized("Edit Card") : String.localized("New Card"))
.navigationBarTitleDisplayMode(.inline)
@@ -71,6 +165,7 @@ struct CardEditorView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Save")) { saveCard() }
+ .bold()
.disabled(!isFormValid)
}
}
@@ -81,123 +176,829 @@ struct CardEditorView: View {
}
}
}
+ .onChange(of: selectedLogo) { _, newValue in
+ Task {
+ if let data = try? await newValue?.loadTransferable(type: Data.self) {
+ logoData = data
+ }
+ }
+ }
.onAppear { loadCardData() }
+ .sheet(isPresented: $showingPreview) {
+ CardPreviewSheet(card: buildPreviewCard())
+ }
}
}
}
-// MARK: - Form Sections
+// MARK: - Card Style Section
-private extension CardEditorView {
- var previewSection: some View {
- Section {
- EditorCardPreview(
- displayName: displayName.isEmpty ? String.localized("Your Name") : displayName,
- role: role.isEmpty ? String.localized("Your Role") : role,
- company: company.isEmpty ? String.localized("Company") : company,
- label: label,
- avatarSystemName: avatarSystemName,
- theme: selectedTheme,
- photoData: photoData
- )
- }
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets())
- }
+private struct CardStyleSection: View {
+ @Binding var selectedTheme: CardTheme
- var photoSection: some View {
- Section(String.localized("Photo")) {
- PhotoPickerRow(
- selectedPhoto: $selectedPhoto,
- photoData: $photoData,
- avatarSystemName: avatarSystemName
- )
- }
- }
-
- var personalSection: some View {
- Section(String.localized("Personal Information")) {
- TextField(String.localized("Full Name"), text: $displayName)
- .textContentType(.name)
- TextField(String.localized("Pronouns"), text: $pronouns)
- .accessibilityHint(String.localized("e.g. she/her, he/him, they/them"))
- TextField(String.localized("Role / Title"), text: $role)
- .textContentType(.jobTitle)
- TextField(String.localized("Company"), text: $company)
- .textContentType(.organizationName)
- TextField(String.localized("Card Label"), text: $label)
- .accessibilityHint(String.localized("A short label like Work or Personal"))
- TextField(String.localized("Bio"), text: $bio, axis: .vertical)
- .lineLimit(3...6)
- .accessibilityHint(String.localized("A short description about yourself"))
- }
- }
-
- var contactSection: some View {
- Section(String.localized("Contact Details")) {
- TextField(String.localized("Email"), text: $email)
- .textContentType(.emailAddress)
- .keyboardType(.emailAddress)
- .textInputAutocapitalization(.never)
- TextField(String.localized("Phone"), text: $phone)
- .textContentType(.telephoneNumber)
- .keyboardType(.phonePad)
- TextField(String.localized("Website"), text: $website)
- .textContentType(.URL)
- .keyboardType(.URL)
- .textInputAutocapitalization(.never)
- TextField(String.localized("Location"), text: $location)
- .textContentType(.fullStreetAddress)
- }
- }
-
- var socialSection: some View {
- Section(String.localized("Social Media")) {
- SocialLinkField(title: "LinkedIn", placeholder: "linkedin.com/in/username", systemImage: "link", text: $linkedIn)
- SocialLinkField(title: "Twitter / X", placeholder: "twitter.com/username", systemImage: "at", text: $twitter)
- SocialLinkField(title: "Instagram", placeholder: "instagram.com/username", systemImage: "camera", text: $instagram)
- SocialLinkField(title: "Facebook", placeholder: "facebook.com/username", systemImage: "person.2", text: $facebook)
- SocialLinkField(title: "TikTok", placeholder: "tiktok.com/@username", systemImage: "play.rectangle", text: $tiktok)
- SocialLinkField(title: "GitHub", placeholder: "github.com/username", systemImage: "chevron.left.forwardslash.chevron.right", text: $github)
- }
- }
-
- var customLinksSection: some View {
- Section(String.localized("Custom Links")) {
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- TextField(String.localized("Link 1 Title"), text: $customLink1Title)
- TextField(String.localized("Link 1 URL"), text: $customLink1URL)
- .textContentType(.URL)
- .keyboardType(.URL)
- .textInputAutocapitalization(.never)
- }
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- TextField(String.localized("Link 2 Title"), text: $customLink2Title)
- TextField(String.localized("Link 2 URL"), text: $customLink2URL)
- .textContentType(.URL)
- .keyboardType(.URL)
- .textInputAutocapitalization(.never)
- }
- }
- }
-
- var appearanceSection: some View {
- Section(String.localized("Appearance")) {
- AvatarPickerRow(selection: $avatarSystemName)
- Picker(String.localized("Theme"), selection: $selectedTheme) {
- ForEach(CardTheme.all) { theme in
- HStack {
- Circle()
- .fill(theme.primaryColor)
- .frame(width: Design.Spacing.large, height: Design.Spacing.large)
- Text(theme.localizedName)
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ Text("Card style")
+ .font(.headline)
+ .foregroundStyle(Color.Text.primary)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: Design.Spacing.medium) {
+ // Rainbow/custom option
+ ColorSwatchButton(
+ isSelected: false,
+ gradient: LinearGradient(
+ colors: [.red, .orange, .yellow, .green, .blue, .purple],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ ) { }
+
+ ForEach(CardTheme.all) { theme in
+ ColorSwatchButton(
+ isSelected: selectedTheme == theme,
+ color: theme.primaryColor
+ ) {
+ selectedTheme = theme
+ }
}
- .tag(theme)
+ }
+ .padding(.horizontal, Design.Spacing.xxSmall)
+ }
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+private struct ColorSwatchButton: View {
+ let isSelected: Bool
+ var color: Color? = nil
+ var gradient: LinearGradient? = nil
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Group {
+ if let gradient {
+ Circle().fill(gradient)
+ } else if let color {
+ Circle().fill(color)
}
}
- Picker(String.localized("Layout"), selection: $selectedLayout) {
- ForEach(CardLayoutStyle.allCases) { layout in
- Text(layout.displayName).tag(layout)
+ .frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
+ .overlay(
+ Circle()
+ .stroke(isSelected ? Color.Text.primary : .clear, lineWidth: Design.LineWidth.medium)
+ .padding(Design.Spacing.xxSmall)
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
+ }
+}
+
+// MARK: - Images & Layout Section
+
+private struct ImagesLayoutSection: View {
+ @Binding var selectedPhoto: PhotosPickerItem?
+ @Binding var photoData: Data?
+ @Binding var selectedLogo: PhotosPickerItem?
+ @Binding var logoData: Data?
+ let avatarSystemName: String
+ let selectedTheme: CardTheme
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ Text("Images & layout")
+ .font(.headline)
+ .foregroundStyle(Color.Text.primary)
+
+ // Card preview with edit buttons
+ ZStack(alignment: .bottomLeading) {
+ // Banner
+ ZStack {
+ LinearGradient(
+ colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+
+ // Logo
+ ZStack {
+ if let logoData, let uiImage = UIImage(data: logoData) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFit()
+ .frame(height: Design.CardSize.logoSize)
+ }
+
+ // Edit logo button
+ VStack {
+ HStack {
+ Spacer()
+ PhotosPicker(selection: $selectedLogo, matching: .images) {
+ Image(systemName: "pencil")
+ .font(.caption)
+ .padding(Design.Spacing.small)
+ .background(.ultraThinMaterial)
+ .clipShape(.circle)
+ }
+ .buttonStyle(.plain)
+ }
+ Spacer()
+ }
+ .padding(Design.Spacing.small)
+ }
+ }
+ .frame(height: Design.CardSize.bannerHeight)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+
+ // Profile photo with edit button
+ ZStack(alignment: .bottomTrailing) {
+ ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme)
+
+ PhotosPicker(selection: $selectedPhoto, matching: .images) {
+ Image(systemName: "pencil")
+ .font(.caption2)
+ .padding(Design.Spacing.xSmall)
+ .background(.ultraThinMaterial)
+ .clipShape(.circle)
+ }
+ .buttonStyle(.plain)
+ }
+ .offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap)
+ }
+ .padding(.bottom, Design.CardSize.avatarOverlap)
+
+ // Add cover photo button
+ if logoData == nil {
+ PhotosPicker(selection: $selectedLogo, matching: .images) {
+ Label("Cover photo", systemImage: "plus")
+ .font(.subheadline)
+ .foregroundStyle(Color.Accent.red)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+private struct ProfilePhotoView: View {
+ let photoData: Data?
+ let avatarSystemName: String
+ let theme: CardTheme
+
+ var body: some View {
+ Group {
+ if let photoData, let uiImage = UIImage(data: photoData) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ } else {
+ Image(systemName: avatarSystemName)
+ .font(.title)
+ .foregroundStyle(theme.textColor)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(theme.accentColor)
+ }
+ }
+ .frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
+ .clipShape(.circle)
+ .overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
+ }
+}
+
+// MARK: - Personal Details Section
+
+private struct PersonalDetailsSection: View {
+ @Binding var displayName: String
+ @Binding var prefix: String
+ @Binding var firstName: String
+ @Binding var middleName: String
+ @Binding var lastName: String
+ @Binding var suffix: String
+ @Binding var maidenName: String
+ @Binding var preferredName: String
+ @Binding var pronouns: String
+ @Binding var showNameDetails: Bool
+
+ /// Computed display name with special formatting:
+ /// - Preferred name in quotes: "Bubba"
+ /// - Maiden name in parentheses: (Hackney)
+ /// - Pronouns in parentheses: (He/Him)
+ private var computedName: String {
+ if !displayName.isEmpty { return displayName }
+
+ var parts: [String] = []
+
+ // Prefix (Dr, Mr, Ms, etc.)
+ if !prefix.isEmpty {
+ parts.append(prefix)
+ }
+
+ // First name
+ if !firstName.isEmpty {
+ parts.append(firstName)
+ }
+
+ // Preferred name in quotes
+ if !preferredName.isEmpty {
+ parts.append("\"\(preferredName)\"")
+ }
+
+ // Middle name
+ if !middleName.isEmpty {
+ parts.append(middleName)
+ }
+
+ // Last name
+ if !lastName.isEmpty {
+ parts.append(lastName)
+ }
+
+ // Suffix (Jr, III, etc.)
+ if !suffix.isEmpty {
+ parts.append(suffix)
+ }
+
+ // Maiden name in parentheses
+ if !maidenName.isEmpty {
+ parts.append("(\(maidenName))")
+ }
+
+ // Pronouns in parentheses
+ if !pronouns.isEmpty {
+ parts.append("(\(pronouns))")
+ }
+
+ return parts.joined(separator: " ")
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ Text("Personal details")
+ .font(.headline)
+ .foregroundStyle(Color.Text.primary)
+
+ // Name row with expand button
+ Button {
+ withAnimation { showNameDetails.toggle() }
+ } label: {
+ HStack {
+ Text(computedName.isEmpty ? "Full Name" : computedName)
+ .foregroundStyle(computedName.isEmpty ? Color.Text.secondary : Color.Text.primary)
+ Spacer()
+ Image(systemName: showNameDetails ? "chevron.up" : "chevron.down")
+ .foregroundStyle(Color.Accent.red)
+ }
+ }
+ .buttonStyle(.plain)
+
+ Divider()
+ .overlay(Color.Accent.red)
+
+ if showNameDetails {
+ VStack(spacing: Design.Spacing.small) {
+ EditorTextField(placeholder: "Prefix (e.g. Dr., Mr., Ms.)", text: $prefix)
+ EditorTextField(placeholder: "First Name", text: $firstName)
+ EditorTextField(placeholder: "Middle Name", text: $middleName)
+ EditorTextField(placeholder: "Last Name", text: $lastName)
+ EditorTextField(placeholder: "Suffix (e.g. Jr., III)", text: $suffix)
+ EditorTextField(placeholder: "Maiden Name", text: $maidenName)
+ EditorTextField(placeholder: "Preferred Name", text: $preferredName)
+ EditorTextField(placeholder: "Pronouns (e.g. she/her, he/him)", text: $pronouns)
+ }
+ .padding(.leading, Design.Spacing.large)
+ }
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+// MARK: - Professional Section
+
+private struct ProfessionalSection: View {
+ @Binding var role: String
+ @Binding var department: String
+ @Binding var company: String
+ @Binding var headline: String
+ @Binding var accreditations: String
+ @Binding var label: String
+
+ @State private var accreditationInput = ""
+ @State private var editingIndex: Int? = nil
+
+ private var accreditationsList: [String] {
+ accreditations.split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ EditorTextField(placeholder: "Job Title", text: $role)
+ EditorTextField(placeholder: "Department", text: $department)
+ EditorTextField(placeholder: "Company", text: $company)
+ EditorTextField(placeholder: "Headline", text: $headline)
+
+ // Accreditations section
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text("Accreditations")
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+
+ // Input row
+ HStack(spacing: Design.Spacing.small) {
+ TextField("e.g. MBA, CPA, PhD", text: $accreditationInput)
+ .textFieldStyle(.roundedBorder)
+
+ if editingIndex != nil {
+ // Editing mode - show check and delete
+ Button {
+ saveEdit()
+ } label: {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(Color.Accent.mint)
+ }
+ .buttonStyle(.plain)
+ .disabled(accreditationInput.trimmingCharacters(in: .whitespaces).isEmpty)
+
+ Button {
+ deleteEditing()
+ } label: {
+ Image(systemName: "trash.circle.fill")
+ .foregroundStyle(Color.Accent.red)
+ }
+ .buttonStyle(.plain)
+ } else {
+ // Add mode
+ Button {
+ addAccreditation()
+ } label: {
+ Image(systemName: "plus.circle.fill")
+ .foregroundStyle(Color.Accent.red)
+ }
+ .buttonStyle(.plain)
+ .disabled(accreditationInput.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ }
+
+ // Tag bubbles
+ if !accreditationsList.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: Design.Spacing.small) {
+ ForEach(accreditationsList.indices, id: \.self) { index in
+ AccreditationTagView(
+ text: accreditationsList[index],
+ isEditing: editingIndex == index
+ ) {
+ startEditing(index: index)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Card label picker
+ VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
+ Text("Card Label")
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+
+ HStack(spacing: Design.Spacing.small) {
+ ForEach(["Work", "Personal", "Creative", "Other"], id: \.self) { option in
+ LabelChip(title: option, isSelected: label == option) {
+ label = option
+ }
+ }
+ }
+ }
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+
+ private func addAccreditation() {
+ let trimmed = accreditationInput.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return }
+
+ var list = accreditationsList
+ list.append(trimmed)
+ accreditations = list.joined(separator: ", ")
+ accreditationInput = ""
+ }
+
+ private func startEditing(index: Int) {
+ editingIndex = index
+ accreditationInput = accreditationsList[index]
+ }
+
+ private func saveEdit() {
+ guard let index = editingIndex else { return }
+ let trimmed = accreditationInput.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return }
+
+ var list = accreditationsList
+ if index < list.count {
+ list[index] = trimmed
+ accreditations = list.joined(separator: ", ")
+ }
+
+ editingIndex = nil
+ accreditationInput = ""
+ }
+
+ private func deleteEditing() {
+ guard let index = editingIndex else { return }
+
+ var list = accreditationsList
+ if index < list.count {
+ list.remove(at: index)
+ accreditations = list.joined(separator: ", ")
+ }
+
+ editingIndex = nil
+ accreditationInput = ""
+ }
+}
+
+private struct AccreditationTagView: View {
+ let text: String
+ let isEditing: Bool
+ let onTap: () -> Void
+
+ var body: some View {
+ Button(action: onTap) {
+ Text(text)
+ .font(.subheadline)
+ .padding(.horizontal, Design.Spacing.medium)
+ .padding(.vertical, Design.Spacing.xSmall)
+ .background(isEditing ? Color.Accent.red : Color.AppBackground.accent)
+ .foregroundStyle(isEditing ? .white : Color.Text.primary)
+ .clipShape(.capsule)
+ .overlay(
+ Capsule()
+ .stroke(isEditing ? Color.Accent.red : .clear, lineWidth: Design.LineWidth.thin)
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(text)
+ .accessibilityHint("Tap to edit this accreditation")
+ }
+}
+
+private struct LabelChip: View {
+ let title: String
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(title)
+ .font(.caption)
+ .padding(.horizontal, Design.Spacing.medium)
+ .padding(.vertical, Design.Spacing.xSmall)
+ .background(isSelected ? Color.Accent.red : Color.AppBackground.accent)
+ .foregroundStyle(isSelected ? .white : Color.Text.primary)
+ .clipShape(.capsule)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+// MARK: - Contact Fields Section
+
+private struct ContactFieldsSection: View {
+ @Binding var email: String
+ @Binding var emailLabel: String
+ @Binding var phone: String
+ @Binding var phoneLabel: String
+ @Binding var phoneExtension: String
+ @Binding var website: String
+ @Binding var location: String
+ @Binding var bio: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ Text("Hold each field below to re-order it")
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ .frame(maxWidth: .infinity)
+ .padding(Design.Spacing.medium)
+ .background(Color.AppBackground.accent)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+
+ // Email field
+ ContactFieldRow(
+ systemImage: "envelope.fill",
+ content: {
+ VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
+ TextField("Email", text: $email)
+ .textContentType(.emailAddress)
+ .keyboardType(.emailAddress)
+ .textInputAutocapitalization(.never)
+
+ Divider()
+
+ TextField("Label", text: $emailLabel)
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ }
+ )
+
+ // Phone field
+ ContactFieldRow(
+ systemImage: "phone.fill",
+ content: {
+ VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
+ HStack {
+ TextField("Phone", text: $phone)
+ .textContentType(.telephoneNumber)
+ .keyboardType(.phonePad)
+
+ TextField("Ext.", text: $phoneExtension)
+ .frame(width: Design.CardSize.avatarSize)
+ .keyboardType(.phonePad)
+ }
+
+ Divider()
+
+ TextField("Label", text: $phoneLabel)
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ }
+ )
+
+ // Website field
+ ContactFieldRow(
+ systemImage: "globe",
+ content: {
+ TextField("Website", text: $website)
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .textInputAutocapitalization(.never)
+ }
+ )
+
+ // Location field
+ ContactFieldRow(
+ systemImage: "location.fill",
+ content: {
+ TextField("Location", text: $location)
+ .textContentType(.fullStreetAddress)
+ }
+ )
+
+ // Bio field
+ ContactFieldRow(
+ systemImage: "text.alignleft",
+ content: {
+ TextField("Bio", text: $bio, axis: .vertical)
+ .lineLimit(3...6)
+ }
+ )
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+private struct ContactFieldRow: View {
+ let systemImage: String
+ @ViewBuilder let content: Content
+
+ var body: some View {
+ HStack(alignment: .top, spacing: Design.Spacing.medium) {
+ Image(systemName: systemImage)
+ .font(.body)
+ .foregroundStyle(Color.Text.secondary)
+ .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
+ .background(Color.AppBackground.accent)
+ .clipShape(.circle)
+
+ VStack(alignment: .leading) {
+ content
+ Divider()
+ }
+
+ Button(role: .destructive) { } label: {
+ Image(systemName: "xmark")
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+}
+
+// MARK: - Social Links Section
+
+private struct SocialLinksSection: View {
+ @Binding var linkedIn: String
+ @Binding var twitter: String
+ @Binding var instagram: String
+ @Binding var facebook: String
+ @Binding var tiktok: String
+ @Binding var github: String
+ @Binding var threads: String
+ @Binding var telegram: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ Text("Social Media")
+ .font(.headline)
+ .foregroundStyle(Color.Text.primary)
+
+ SocialLinkRow(platform: "LinkedIn", placeholder: "linkedin.com/in/username", color: Color.Social.linkedIn, text: $linkedIn, suggestion: "Connect with me on LinkedIn")
+ SocialLinkRow(platform: "X / Twitter", placeholder: "x.com/username", color: Color.Social.twitter, text: $twitter, suggestion: "Follow me on X")
+ SocialLinkRow(platform: "Instagram", placeholder: "instagram.com/username", color: Color.Social.instagram, text: $instagram, suggestion: "Follow me on Instagram")
+ SocialLinkRow(platform: "Facebook", placeholder: "facebook.com/username", color: Color.Social.facebook, text: $facebook, suggestion: nil)
+ SocialLinkRow(platform: "TikTok", placeholder: "tiktok.com/@username", color: Color.Social.tiktok, text: $tiktok, suggestion: "Follow me on TikTok")
+ SocialLinkRow(platform: "GitHub", placeholder: "github.com/username", color: Color.Social.github, text: $github, suggestion: "View our work on GitHub")
+ SocialLinkRow(platform: "Threads", placeholder: "threads.net/@username", color: Color.Social.threads, text: $threads, suggestion: "Follow me on Threads")
+ SocialLinkRow(platform: "Telegram", placeholder: "t.me/username", color: Color.Social.telegram, text: $telegram, suggestion: "Connect with me on Telegram")
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+private struct SocialLinkRow: View {
+ let platform: String
+ let placeholder: String
+ let color: Color
+ @Binding var text: String
+ let suggestion: String?
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.medium) {
+ Circle()
+ .fill(color)
+ .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
+ .overlay(
+ Text(String(platform.prefix(2)))
+ .font(.caption2)
+ .bold()
+ .foregroundStyle(.white)
+ )
+
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ TextField(platform, text: $text, prompt: Text(placeholder).foregroundStyle(Color.Text.secondary))
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .textInputAutocapitalization(.never)
+
+ if let suggestion, text.isEmpty {
+ Button {
+ // Could pre-fill with suggestion
+ } label: {
+ Text(suggestion)
+ .font(.caption2)
+ .padding(.horizontal, Design.Spacing.small)
+ .padding(.vertical, Design.Spacing.xxSmall)
+ .background(Color.AppBackground.accent)
+ .clipShape(.capsule)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Payment Links Section
+
+private struct PaymentLinksSection: View {
+ @Binding var venmo: String
+ @Binding var cashApp: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ Text("Payment")
+ .font(.headline)
+ .foregroundStyle(Color.Text.primary)
+
+ SocialLinkRow(platform: "Venmo", placeholder: "@username", color: Color.Social.venmo, text: $venmo, suggestion: "Pay via Venmo")
+ SocialLinkRow(platform: "Cash App", placeholder: "$username", color: Color.Social.cashApp, text: $cashApp, suggestion: "Pay via Cash App")
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+// MARK: - Bio Section
+
+private struct BioSection: View {
+ @Binding var bio: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text("About")
+ .font(.subheadline.bold())
+ .foregroundStyle(Color.Text.primary)
+
+ TextField("Tell people about yourself...", text: $bio, axis: .vertical)
+ .lineLimit(3...8)
+ .textFieldStyle(.plain)
+
+ Divider()
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+// MARK: - Custom Links Section
+
+private struct CustomLinksSection: View {
+ @Binding var customLink1Title: String
+ @Binding var customLink1URL: String
+ @Binding var customLink2Title: String
+ @Binding var customLink2URL: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ Text("Custom Links")
+ .font(.headline)
+ .foregroundStyle(Color.Text.primary)
+
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ EditorTextField(placeholder: "Link 1 Title", text: $customLink1Title)
+ EditorTextField(placeholder: "Link 1 URL", text: $customLink1URL)
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .textInputAutocapitalization(.never)
+ }
+
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ EditorTextField(placeholder: "Link 2 Title", text: $customLink2Title)
+ EditorTextField(placeholder: "Link 2 URL", text: $customLink2URL)
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .textInputAutocapitalization(.never)
+ }
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+// MARK: - Supporting Views
+
+private struct EditorTextField: View {
+ let placeholder: String
+ @Binding var text: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ TextField(placeholder, text: $text)
+ Divider()
+ }
+ }
+}
+
+private struct PreviewCardButton: View {
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text("Preview card")
+ .font(.headline)
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity)
+ .padding(Design.Spacing.medium)
+ .background(Color.Text.primary)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.vertical, Design.Spacing.medium)
+ .background(.ultraThinMaterial)
+ }
+}
+
+private struct CardPreviewSheet: View {
+ let card: BusinessCard
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ BusinessCardView(card: card)
+ .padding(Design.Spacing.large)
+ }
+ .background(Color.AppBackground.base)
+ .navigationTitle(String.localized("Preview"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button(String.localized("Done")) { dismiss() }
}
}
}
@@ -210,13 +1011,26 @@ private extension CardEditorView {
func loadCardData() {
guard let card else { return }
displayName = card.displayName
- role = card.role
- company = card.company
- label = card.label
+ prefix = card.prefix
+ firstName = card.firstName
+ middleName = card.middleName
+ lastName = card.lastName
+ suffix = card.suffix
+ maidenName = card.maidenName
+ preferredName = card.preferredName
pronouns = card.pronouns
+ role = card.role
+ department = card.department
+ company = card.company
+ headline = card.headline
+ label = card.label
bio = card.bio
+ accreditations = card.accreditations
email = card.email
+ emailLabel = card.emailLabel
phone = card.phone
+ phoneLabel = card.phoneLabel
+ phoneExtension = card.phoneExtension
website = card.website
location = card.location
linkedIn = card.linkedIn
@@ -225,17 +1039,28 @@ private extension CardEditorView {
facebook = card.facebook
tiktok = card.tiktok
github = card.github
+ threads = card.threads
+ telegram = card.telegram
+ venmo = card.venmo
+ cashApp = card.cashApp
customLink1Title = card.customLink1Title
customLink1URL = card.customLink1URL
customLink2Title = card.customLink2Title
customLink2URL = card.customLink2URL
avatarSystemName = card.avatarSystemName
+
+ // Load contact fields into the array
+ contactFields = buildContactFieldsArray(from: card)
selectedTheme = card.theme
selectedLayout = card.layoutStyle
photoData = card.photoData
+ logoData = card.logoData
}
func saveCard() {
+ // Sync contact fields to individual properties before saving
+ syncContactFieldsToProperties()
+
if let existingCard = card {
updateCard(existingCard)
onSave(existingCard)
@@ -247,14 +1072,27 @@ private extension CardEditorView {
}
func updateCard(_ card: BusinessCard) {
- card.displayName = displayName
- card.role = role
- card.company = company
- card.label = label
+ card.displayName = displayName.isEmpty ? effectiveDisplayName : displayName
+ card.prefix = prefix
+ card.firstName = firstName
+ card.middleName = middleName
+ card.lastName = lastName
+ card.suffix = suffix
+ card.maidenName = maidenName
+ card.preferredName = preferredName
card.pronouns = pronouns
+ card.role = role
+ card.department = department
+ card.company = company
+ card.headline = headline
+ card.label = label
card.bio = bio
+ card.accreditations = accreditations
card.email = email
+ card.emailLabel = emailLabel
card.phone = phone
+ card.phoneLabel = phoneLabel
+ card.phoneExtension = phoneExtension
card.website = website
card.location = location
card.linkedIn = linkedIn
@@ -263,6 +1101,10 @@ private extension CardEditorView {
card.facebook = facebook
card.tiktok = tiktok
card.github = github
+ card.threads = threads
+ card.telegram = telegram
+ card.venmo = venmo
+ card.cashApp = cashApp
card.customLink1Title = customLink1Title
card.customLink1URL = customLink1URL
card.customLink2Title = customLink2Title
@@ -271,146 +1113,217 @@ private extension CardEditorView {
card.theme = selectedTheme
card.layoutStyle = selectedLayout
card.photoData = photoData
+ card.logoData = logoData
+
+ // Save contact fields to the model's array
+ saveContactFieldsToCard(card)
}
func createCard() -> BusinessCard {
- BusinessCard(
- displayName: displayName, role: role, company: company, label: label,
- email: email, phone: phone, website: website, location: location,
+ let newCard = BusinessCard(
+ displayName: displayName.isEmpty ? effectiveDisplayName : displayName,
+ role: role, company: company, label: label,
+ email: email, emailLabel: emailLabel,
+ phone: phone, phoneLabel: phoneLabel, phoneExtension: phoneExtension,
+ website: website, location: location,
isDefault: false, themeName: selectedTheme.name, layoutStyleRawValue: selectedLayout.rawValue,
- avatarSystemName: avatarSystemName, pronouns: pronouns, bio: bio,
+ avatarSystemName: avatarSystemName,
+ prefix: prefix, firstName: firstName, middleName: middleName, lastName: lastName,
+ suffix: suffix, maidenName: maidenName, preferredName: preferredName,
+ pronouns: pronouns, department: department, headline: headline, bio: bio, accreditations: accreditations,
linkedIn: linkedIn, twitter: twitter, instagram: instagram, facebook: facebook,
- tiktok: tiktok, github: github, customLink1Title: customLink1Title, customLink1URL: customLink1URL,
- customLink2Title: customLink2Title, customLink2URL: customLink2URL, photoData: photoData
+ tiktok: tiktok, github: github, threads: threads, telegram: telegram, venmo: venmo, cashApp: cashApp,
+ customLink1Title: customLink1Title, customLink1URL: customLink1URL,
+ customLink2Title: customLink2Title, customLink2URL: customLink2URL,
+ photoData: photoData, logoData: logoData
)
- }
-}
-
-// MARK: - Supporting Views
-
-private struct PhotoPickerRow: View {
- @Binding var selectedPhoto: PhotosPickerItem?
- @Binding var photoData: Data?
- let avatarSystemName: String
-
- var body: some View {
- let hasPhoto = photoData != nil
- let labelText = hasPhoto ? String.localized("Change Photo") : String.localized("Add Photo")
- let accentColor = Color.Accent.red
- HStack(spacing: Design.Spacing.medium) {
- AvatarBadgeView(
- systemName: avatarSystemName,
- accentColor: accentColor,
- photoData: photoData
- )
-
- VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
- PhotosPicker(selection: $selectedPhoto, matching: .images) {
- Text(labelText)
- .foregroundStyle(accentColor)
- }
- if hasPhoto {
- Button(String.localized("Remove Photo"), role: .destructive) {
- photoData = nil
- selectedPhoto = nil
- }
- .font(.caption)
- }
- }
- }
- .accessibilityElement(children: .combine)
- .accessibilityLabel(String.localized("Profile photo"))
+ // Save contact fields to the model's array
+ saveContactFieldsToCard(newCard)
+
+ return newCard
}
-}
-
-private struct SocialLinkField: View {
- let title: String
- let placeholder: String
- let systemImage: String
- @Binding var text: String
- var body: some View {
- HStack(spacing: Design.Spacing.medium) {
- Image(systemName: systemImage)
- .foregroundStyle(Color.Accent.red)
- .frame(width: Design.Spacing.xLarge)
- TextField(title, text: $text, prompt: Text(placeholder))
- .textContentType(.URL)
- .keyboardType(.URL)
- .textInputAutocapitalization(.never)
- }
- .accessibilityLabel(title)
- }
-}
-
-private struct EditorCardPreview: View {
- let displayName: String
- let role: String
- let company: String
- let label: String
- let avatarSystemName: String
- let theme: CardTheme
- let photoData: Data?
-
- private var textColor: Color { theme.textColor }
-
- var body: some View {
- VStack(spacing: Design.Spacing.medium) {
- HStack(spacing: Design.Spacing.medium) {
- AvatarBadgeView(
- systemName: avatarSystemName,
- accentColor: theme.accentColor,
- photoData: photoData,
- borderColor: textColor
- )
- VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
- Text(displayName).font(.headline).bold().foregroundStyle(textColor)
- Text(role).font(.subheadline).foregroundStyle(textColor.opacity(Design.Opacity.almostFull))
- Text(company).font(.caption).foregroundStyle(textColor.opacity(Design.Opacity.medium))
- }
- Spacer(minLength: Design.Spacing.small)
- LabelBadgeView(label: label, accentColor: theme.accentColor, textColor: textColor)
- }
- }
- .padding(Design.Spacing.large)
- .frame(maxWidth: .infinity)
- .background(
- LinearGradient(colors: [theme.primaryColor, theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing)
+ func buildPreviewCard() -> BusinessCard {
+ // Sync contact fields before building preview
+ syncContactFieldsToProperties()
+
+ return BusinessCard(
+ displayName: displayName.isEmpty ? effectiveDisplayName : displayName,
+ role: role, company: company, label: label,
+ email: email, emailLabel: emailLabel,
+ phone: phone, phoneLabel: phoneLabel, phoneExtension: phoneExtension,
+ website: website, location: location,
+ isDefault: false, themeName: selectedTheme.name, layoutStyleRawValue: selectedLayout.rawValue,
+ avatarSystemName: avatarSystemName,
+ prefix: prefix, firstName: firstName, middleName: middleName, lastName: lastName,
+ suffix: suffix, maidenName: maidenName, preferredName: preferredName,
+ pronouns: pronouns, department: department, headline: headline, bio: bio, accreditations: accreditations,
+ linkedIn: linkedIn, twitter: twitter, instagram: instagram, facebook: facebook,
+ tiktok: tiktok, github: github, threads: threads, telegram: telegram, venmo: venmo, cashApp: cashApp,
+ customLink1Title: customLink1Title, customLink1URL: customLink1URL,
+ customLink2Title: customLink2Title, customLink2URL: customLink2URL,
+ photoData: photoData, logoData: logoData
)
- .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)
- .padding(.vertical, Design.Spacing.medium)
}
-}
-
-private struct AvatarPickerRow: View {
- @Binding var selection: String
- private let avatarOptions = [
- "person.crop.circle", "person.crop.circle.fill", "person.crop.square", "person.circle", "sparkles",
- "music.mic", "briefcase.fill", "building.2.fill", "star.fill", "bolt.fill"
- ]
+ // MARK: - Contact Fields Sync
- var body: some View {
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- Text("Icon (if no photo)")
- .font(.subheadline)
- .foregroundStyle(Color.Text.secondary)
- LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Design.Spacing.small) {
- ForEach(avatarOptions, id: \.self) { icon in
- Button { selection = icon } label: {
- Image(systemName: icon)
- .font(.title2)
- .foregroundStyle(selection == icon ? Color.Accent.red : Color.Text.secondary)
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .background(selection == icon ? Color.AppBackground.accent : Color.AppBackground.base)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
- }
- .buttonStyle(.plain)
- .accessibilityLabel(icon)
- .accessibilityAddTraits(selection == icon ? .isSelected : [])
+ /// Builds an array of AddedContactField from a BusinessCard
+ /// Prefers the new contactFields array, falls back to legacy properties for migration
+ func buildContactFieldsArray(from card: BusinessCard) -> [AddedContactField] {
+ // If the card has the new contactFields array, use it
+ if let modelFields = card.contactFields, !modelFields.isEmpty {
+ return card.orderedContactFields.compactMap { $0.toAddedContactField() }
+ }
+
+ // Fall back to legacy properties for migration
+ var fields: [AddedContactField] = []
+
+ if !card.email.isEmpty {
+ fields.append(AddedContactField(fieldType: .email, value: card.email, title: card.emailLabel))
+ }
+ if !card.phone.isEmpty {
+ let phoneValue = card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)"
+ fields.append(AddedContactField(fieldType: .phone, value: phoneValue, title: card.phoneLabel))
+ }
+ if !card.website.isEmpty {
+ fields.append(AddedContactField(fieldType: .website, value: card.website, title: ""))
+ }
+ if !card.location.isEmpty {
+ fields.append(AddedContactField(fieldType: .address, value: card.location, title: ""))
+ }
+ if !card.linkedIn.isEmpty {
+ fields.append(AddedContactField(fieldType: .linkedIn, value: card.linkedIn, title: ""))
+ }
+ if !card.twitter.isEmpty {
+ fields.append(AddedContactField(fieldType: .twitter, value: card.twitter, title: ""))
+ }
+ if !card.instagram.isEmpty {
+ fields.append(AddedContactField(fieldType: .instagram, value: card.instagram, title: ""))
+ }
+ if !card.facebook.isEmpty {
+ fields.append(AddedContactField(fieldType: .facebook, value: card.facebook, title: ""))
+ }
+ if !card.tiktok.isEmpty {
+ fields.append(AddedContactField(fieldType: .tiktok, value: card.tiktok, title: ""))
+ }
+ if !card.github.isEmpty {
+ fields.append(AddedContactField(fieldType: .github, value: card.github, title: ""))
+ }
+ if !card.threads.isEmpty {
+ fields.append(AddedContactField(fieldType: .threads, value: card.threads, title: ""))
+ }
+ if !card.telegram.isEmpty {
+ fields.append(AddedContactField(fieldType: .telegram, value: card.telegram, title: ""))
+ }
+ if !card.venmo.isEmpty {
+ fields.append(AddedContactField(fieldType: .venmo, value: card.venmo, title: ""))
+ }
+ if !card.cashApp.isEmpty {
+ fields.append(AddedContactField(fieldType: .cashApp, value: card.cashApp, title: ""))
+ }
+ if !card.customLink1URL.isEmpty {
+ fields.append(AddedContactField(fieldType: .customLink, value: card.customLink1URL, title: card.customLink1Title))
+ }
+ if !card.customLink2URL.isEmpty {
+ fields.append(AddedContactField(fieldType: .customLink, value: card.customLink2URL, title: card.customLink2Title))
+ }
+
+ return fields
+ }
+
+ /// Saves the contactFields array to the BusinessCard model
+ func saveContactFieldsToCard(_ card: BusinessCard) {
+ // Clear existing contact fields
+ card.contactFields?.removeAll()
+
+ // Add new fields from the UI array
+ for (index, addedField) in contactFields.enumerated() {
+ let value = addedField.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty else { continue }
+
+ let field = ContactField(
+ typeId: addedField.fieldType.id,
+ value: value,
+ title: addedField.title,
+ orderIndex: index
+ )
+ field.card = card
+ if card.contactFields == nil {
+ card.contactFields = []
+ }
+ card.contactFields?.append(field)
+ }
+ }
+
+ /// Syncs the contactFields array back to individual properties
+ func syncContactFieldsToProperties() {
+ // Reset all contact properties
+ email = ""; emailLabel = "Work"
+ phone = ""; phoneLabel = "Cell"; phoneExtension = ""
+ website = ""; location = ""
+ linkedIn = ""; twitter = ""; instagram = ""; facebook = ""
+ tiktok = ""; github = ""; threads = ""; telegram = ""
+ venmo = ""; cashApp = ""
+ customLink1Title = ""; customLink1URL = ""
+ customLink2Title = ""; customLink2URL = ""
+
+ var customLinkCount = 0
+
+ for field in contactFields {
+ let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty else { continue }
+
+ switch field.fieldType.id {
+ case "email":
+ // Take first non-empty email
+ if email.isEmpty {
+ email = value
+ emailLabel = field.title.isEmpty ? "Work" : field.title
}
+ case "phone":
+ // Take first non-empty phone
+ if phone.isEmpty {
+ phone = value
+ phoneLabel = field.title.isEmpty ? "Cell" : field.title
+ }
+ case "website":
+ if website.isEmpty { website = value }
+ case "address":
+ if location.isEmpty { location = value }
+ case "linkedIn":
+ if linkedIn.isEmpty { linkedIn = value }
+ case "twitter":
+ if twitter.isEmpty { twitter = value }
+ case "instagram":
+ if instagram.isEmpty { instagram = value }
+ case "facebook":
+ if facebook.isEmpty { facebook = value }
+ case "tiktok":
+ if tiktok.isEmpty { tiktok = value }
+ case "github":
+ if github.isEmpty { github = value }
+ case "threads":
+ if threads.isEmpty { threads = value }
+ case "telegram":
+ if telegram.isEmpty { telegram = value }
+ case "venmo":
+ if venmo.isEmpty { venmo = value }
+ case "cashApp":
+ if cashApp.isEmpty { cashApp = value }
+ case "customLink":
+ if customLinkCount == 0 {
+ customLink1URL = value
+ customLink1Title = field.title
+ customLinkCount += 1
+ } else if customLinkCount == 1 {
+ customLink2URL = value
+ customLink2Title = field.title
+ customLinkCount += 1
+ }
+ default:
+ break
}
}
}
diff --git a/BusinessCard/Views/Components/AddedContactFieldsView.swift b/BusinessCard/Views/Components/AddedContactFieldsView.swift
new file mode 100644
index 0000000..0327e97
--- /dev/null
+++ b/BusinessCard/Views/Components/AddedContactFieldsView.swift
@@ -0,0 +1,123 @@
+import SwiftUI
+import Bedrock
+
+/// Represents a contact field that has been added
+struct AddedContactField: Identifiable, Equatable {
+ let id: UUID
+ let fieldType: ContactFieldType
+ var value: String
+ var title: String
+
+ init(id: UUID = UUID(), fieldType: ContactFieldType, value: String = "", title: String = "") {
+ self.id = id
+ self.fieldType = fieldType
+ self.value = value
+ self.title = title
+ }
+
+ static func == (lhs: AddedContactField, rhs: AddedContactField) -> Bool {
+ lhs.id == rhs.id && lhs.value == rhs.value && lhs.title == rhs.title
+ }
+}
+
+/// Displays a vertical list of added contact fields with tap to edit
+struct AddedContactFieldsView: View {
+ @Binding var fields: [AddedContactField]
+ let onEdit: (AddedContactField) -> Void
+
+ var body: some View {
+ if fields.isEmpty {
+ EmptyView()
+ } else {
+ VStack(spacing: 0) {
+ ForEach(fields) { field in
+ FieldRow(
+ field: field,
+ onTap: { onEdit(field) },
+ onDelete: { deleteField(field) }
+ )
+
+ if field.id != fields.last?.id {
+ Divider()
+ .padding(.leading, Design.CardSize.avatarSize + Design.Spacing.large + Design.Spacing.medium)
+ }
+ }
+ }
+ .background(Color.AppBackground.elevated)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+ }
+
+ private func deleteField(_ field: AddedContactField) {
+ withAnimation {
+ fields.removeAll { $0.id == field.id }
+ }
+ }
+}
+
+/// A display row for a contact field - tap to edit
+private struct FieldRow: View {
+ let field: AddedContactField
+ let onTap: () -> Void
+ let onDelete: () -> Void
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.medium) {
+ // Icon
+ Circle()
+ .fill(field.fieldType.iconColor)
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .overlay(
+ Image(systemName: field.fieldType.systemImage)
+ .font(.title3)
+ .foregroundStyle(.white)
+ )
+
+ // Content - tap to edit
+ Button(action: onTap) {
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text(field.value.isEmpty ? field.fieldType.valuePlaceholder : field.value)
+ .font(.subheadline)
+ .foregroundStyle(field.value.isEmpty ? Color.Text.secondary : Color.Text.primary)
+ .lineLimit(1)
+
+ Text(field.title.isEmpty ? field.fieldType.displayName : field.title)
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ .lineLimit(1)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+
+ // Delete button
+ Button(action: onDelete) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title3)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(String(localized: "Delete"))
+ .accessibilityHint(String(localized: "Removes this field"))
+ }
+ .padding(Design.Spacing.medium)
+ .contentShape(.rect)
+ }
+}
+
+#Preview {
+ @Previewable @State var fields: [AddedContactField] = [
+ AddedContactField(fieldType: .email, value: "matt@example.com", title: "Work"),
+ AddedContactField(fieldType: .email, value: "personal@example.com", title: "Personal"),
+ AddedContactField(fieldType: .phone, value: "+1 (555) 123-4567", title: "Cell"),
+ AddedContactField(fieldType: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me")
+ ]
+
+ ScrollView {
+ AddedContactFieldsView(fields: $fields) { field in
+ print("Edit: \(field.fieldType.displayName)")
+ }
+ .padding()
+ }
+ .background(Color.AppBackground.base)
+}
diff --git a/BusinessCard/Views/Components/ContactFieldPickerView.swift b/BusinessCard/Views/Components/ContactFieldPickerView.swift
new file mode 100644
index 0000000..92bfa00
--- /dev/null
+++ b/BusinessCard/Views/Components/ContactFieldPickerView.swift
@@ -0,0 +1,74 @@
+import SwiftUI
+import Bedrock
+
+/// Grid view for selecting contact field types to add
+struct ContactFieldPickerView: View {
+ let onSelect: (ContactFieldType) -> Void
+
+ private let columns = Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.medium), count: 3)
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.medium) {
+ HStack {
+ Text("Tap a field below to add it")
+ .font(.subheadline)
+ .foregroundStyle(Color.Text.primary)
+
+ Spacer()
+
+ Image(systemName: "plus")
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ .padding(.horizontal, Design.Spacing.medium)
+ .padding(.vertical, Design.Spacing.small)
+ .background(Color.AppBackground.accent)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+
+ LazyVGrid(columns: columns, spacing: Design.Spacing.large) {
+ ForEach(ContactFieldType.allCases) { fieldType in
+ FieldTypeButton(fieldType: fieldType) {
+ onSelect(fieldType)
+ }
+ }
+ }
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+private struct FieldTypeButton: View {
+ let fieldType: ContactFieldType
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ VStack(spacing: Design.Spacing.small) {
+ Circle()
+ .fill(fieldType.iconColor)
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .overlay(
+ Image(systemName: fieldType.systemImage)
+ .font(.title3)
+ .foregroundStyle(.white)
+ )
+
+ Text(fieldType.displayName)
+ .font(.caption)
+ .foregroundStyle(Color.Text.primary)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .frame(height: Design.Spacing.xLarge * 2)
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(fieldType.displayName)
+ }
+}
+
+#Preview {
+ ContactFieldPickerView { fieldType in
+ print("Selected: \(fieldType.displayName)")
+ }
+}
diff --git a/BusinessCard/Views/Components/ContactFieldsManagerView.swift b/BusinessCard/Views/Components/ContactFieldsManagerView.swift
new file mode 100644
index 0000000..fa461ae
--- /dev/null
+++ b/BusinessCard/Views/Components/ContactFieldsManagerView.swift
@@ -0,0 +1,87 @@
+import SwiftUI
+import Bedrock
+
+/// Manages all contact, social, and payment fields with a picker-based UI
+/// Tap to add opens a sheet, tap on existing field opens edit sheet
+struct ContactFieldsManagerView: View {
+ @Binding var fields: [AddedContactField]
+
+ @State private var selectedFieldTypeForAdd: ContactFieldType?
+ @State private var fieldToEdit: AddedContactField?
+
+ var body: some View {
+ VStack(spacing: Design.Spacing.medium) {
+ // Added fields list
+ AddedContactFieldsView(fields: $fields) { field in
+ fieldToEdit = field
+ }
+
+ // Field picker grid
+ ContactFieldPickerView { fieldType in
+ selectedFieldTypeForAdd = fieldType
+ }
+ }
+ .sheet(item: $selectedFieldTypeForAdd) { fieldType in
+ ContactFieldEditorSheet(
+ fieldType: fieldType,
+ initialValue: "",
+ initialTitle: fieldType.titleSuggestions.first ?? "",
+ onSave: { value, title in
+ addField(fieldType: fieldType, value: value, title: title)
+ }
+ )
+ }
+ .sheet(item: $fieldToEdit) { field in
+ ContactFieldEditorSheet(
+ fieldType: field.fieldType,
+ initialValue: field.value,
+ initialTitle: field.title,
+ onSave: { value, title in
+ updateField(id: field.id, value: value, title: title)
+ },
+ onDelete: {
+ deleteField(id: field.id)
+ }
+ )
+ }
+ }
+
+ private func addField(fieldType: ContactFieldType, value: String, title: String) {
+ guard !value.isEmpty else { return }
+ withAnimation {
+ let newField = AddedContactField(
+ fieldType: fieldType,
+ value: value,
+ title: title
+ )
+ fields.append(newField)
+ }
+ }
+
+ private func updateField(id: UUID, value: String, title: String) {
+ if let index = fields.firstIndex(where: { $0.id == id }) {
+ withAnimation {
+ fields[index].value = value
+ fields[index].title = title
+ }
+ }
+ }
+
+ private func deleteField(id: UUID) {
+ withAnimation {
+ fields.removeAll { $0.id == id }
+ }
+ }
+}
+
+#Preview {
+ @Previewable @State var fields: [AddedContactField] = [
+ AddedContactField(fieldType: .email, value: "matt@example.com", title: "Work"),
+ AddedContactField(fieldType: .phone, value: "+1 (555) 123-4567", title: "Cell")
+ ]
+
+ ScrollView {
+ ContactFieldsManagerView(fields: $fields)
+ }
+ .background(Color.AppBackground.base)
+}
diff --git a/BusinessCard/Views/ShareCardView.swift b/BusinessCard/Views/ShareCardView.swift
index 6ef2a38..e9c4eaf 100644
--- a/BusinessCard/Views/ShareCardView.swift
+++ b/BusinessCard/Views/ShareCardView.swift
@@ -7,40 +7,48 @@ struct ShareCardView: View {
@State private var showingWalletAlert = false
@State private var showingNfcAlert = false
@State private var showingContactSheet = false
+ @State private var shareOffline = false
@State private var recipientName = ""
@State private var recipientRole = ""
@State private var recipientCompany = ""
var body: some View {
NavigationStack {
- ScrollView {
- VStack(spacing: Design.Spacing.large) {
- Text("Share with anyone")
- .font(.title2)
- .bold()
- .foregroundStyle(Color.Text.primary)
-
- if let card = appState.cardStore.selectedCard {
- QRCodeCardView(card: card)
- ShareOptionsView(
- card: card,
- shareLinkService: appState.shareLinkService,
- showWallet: { showingWalletAlert = true },
- showNfc: { showingNfcAlert = true }
- )
- TrackShareButton { showingContactSheet = true }
- } else {
- EmptyStateView(
- title: String.localized("No card selected"),
- message: String.localized("Choose a card in the My Cards tab to start sharing.")
- )
+ ZStack {
+ // Dark background
+ Color.ShareSheet.background
+ .ignoresSafeArea()
+
+ ScrollView {
+ VStack(spacing: Design.Spacing.xLarge) {
+ if let card = appState.cardStore.selectedCard {
+ // QR Code section
+ QRCodeSection(card: card)
+
+ // Share options
+ ShareOptionsSection(
+ card: card,
+ shareLinkService: appState.shareLinkService,
+ shareOffline: $shareOffline,
+ showWallet: { showingWalletAlert = true },
+ showNfc: { showingNfcAlert = true }
+ )
+
+ // Track share
+ TrackShareSection { showingContactSheet = true }
+ } else {
+ EmptyShareState()
+ }
}
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.vertical, Design.Spacing.xLarge)
}
- .padding(.horizontal, Design.Spacing.large)
- .padding(.vertical, Design.Spacing.xLarge)
}
- .background(Color.AppBackground.base)
- .navigationTitle(String.localized("Send Work Card"))
+ .navigationTitle(String.localized("Send Your Card"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbarColorScheme(.dark, for: .navigationBar)
+ .toolbarBackground(Color.ShareSheet.background, for: .navigationBar)
+ .toolbarBackground(.visible, for: .navigationBar)
.alert(String.localized("Apple Wallet"), isPresented: $showingWalletAlert) {
Button(String.localized("OK")) { }
} message: {
@@ -77,128 +85,215 @@ struct ShareCardView: View {
}
}
-// MARK: - QR Code Display
+// MARK: - QR Code Section
-private struct QRCodeCardView: View {
+private struct QRCodeSection: View {
let card: BusinessCard
- private var textColor: Color { card.theme.textColor }
-
var body: some View {
- VStack(spacing: Design.Spacing.medium) {
+ VStack(spacing: Design.Spacing.large) {
+ // QR Code
QRCodeView(payload: card.vCardPayload)
- .frame(width: Design.CardSize.qrSize, height: Design.CardSize.qrSize)
- .padding(Design.Spacing.medium)
- .background(Color.AppBackground.elevated)
+ .frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
+ .padding(Design.Spacing.large)
+ .background(Color.white)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
-
+
+ // Instruction text
Text("Point your camera at the QR code to receive the card")
.font(.subheadline)
- .foregroundStyle(textColor.opacity(Design.Opacity.strong))
+ .foregroundStyle(Color.ShareSheet.secondaryText)
.multilineTextAlignment(.center)
}
- .padding(Design.Spacing.large)
- .background(card.theme.primaryColor)
+ .frame(maxWidth: .infinity)
+ .padding(Design.Spacing.xLarge)
+ .background(Color.ShareSheet.cardBackground)
.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.offsetSmall
- )
}
}
-// MARK: - Share Options
+// MARK: - Share Options Section
-private struct ShareOptionsView: View {
+private struct ShareOptionsSection: View {
let card: BusinessCard
let shareLinkService: ShareLinkProviding
+ @Binding var shareOffline: Bool
let showWallet: () -> Void
let showNfc: () -> Void
var body: some View {
- VStack(spacing: Design.Spacing.small) {
- ShareOptionRow.share(
+ VStack(spacing: 0) {
+ // Offline toggle
+ ShareOfflineToggle(isOn: $shareOffline)
+
+ Divider()
+ .overlay(Color.ShareSheet.rowBackground)
+
+ // Copy link
+ ShareOptionButton.share(
title: String.localized("Copy link"),
- systemImage: "link",
+ systemImage: "doc.on.doc",
item: shareLinkService.shareURL(for: card)
)
- ShareOptionRow.link(
- title: String.localized("Text your card"),
- systemImage: "message",
- url: shareLinkService.smsURL(for: card)
- )
- ShareOptionRow.link(
- title: String.localized("Email your card"),
- systemImage: "envelope",
- url: shareLinkService.emailURL(for: card)
- )
- ShareOptionRow.link(
- title: String.localized("Send via WhatsApp"),
- systemImage: "message.fill",
- url: shareLinkService.whatsappURL(for: card)
- )
- ShareOptionRow.link(
- title: String.localized("Send via LinkedIn"),
- systemImage: "link.circle",
- url: shareLinkService.linkedInURL(for: card)
- )
- ShareOptionRow.action(
- title: String.localized("Add to Apple Wallet"),
- systemImage: "wallet.pass",
- action: showWallet
- )
- ShareOptionRow.action(
- title: String.localized("Share via NFC"),
- systemImage: "dot.radiowaves.left.and.right",
- action: showNfc
- )
+
+ Divider()
+ .overlay(Color.ShareSheet.rowBackground)
+
+ // Message options group
+ VStack(spacing: 0) {
+ ShareOptionButton.link(
+ title: String.localized("Text your card"),
+ systemImage: "message.fill",
+ url: shareLinkService.smsURL(for: card)
+ )
+
+ Divider()
+ .overlay(Color.ShareSheet.rowBackground)
+
+ ShareOptionButton.link(
+ title: String.localized("Email your card"),
+ systemImage: "envelope.fill",
+ url: shareLinkService.emailURL(for: card)
+ )
+
+ Divider()
+ .overlay(Color.ShareSheet.rowBackground)
+
+ ShareOptionButton.link(
+ title: String.localized("Send via WhatsApp"),
+ systemImage: "ellipsis.message.fill",
+ iconColor: Color.Social.whatsapp,
+ url: shareLinkService.whatsappURL(for: card)
+ )
+
+ Divider()
+ .overlay(Color.ShareSheet.rowBackground)
+
+ ShareOptionButton.link(
+ title: String.localized("Send via LinkedIn"),
+ systemImage: "person.2.fill",
+ iconColor: Color.Social.linkedIn,
+ url: shareLinkService.linkedInURL(for: card)
+ )
+
+ Divider()
+ .overlay(Color.ShareSheet.rowBackground)
+
+ ShareOptionButton.action(
+ title: String.localized("Send another way"),
+ systemImage: "ellipsis",
+ action: {}
+ )
+ }
}
- .padding(Design.Spacing.large)
- .background(Color.AppBackground.elevated)
+ .background(Color.ShareSheet.cardBackground)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
}
-// MARK: - Track Button
+// MARK: - Share Offline Toggle
-private struct TrackShareButton: View {
+private struct ShareOfflineToggle: View {
+ @Binding var isOn: Bool
+
+ var body: some View {
+ Toggle(isOn: $isOn) {
+ HStack(spacing: Design.Spacing.medium) {
+ Image(systemName: "wifi.slash")
+ .foregroundStyle(Color.ShareSheet.secondaryText)
+ Text("Share card offline")
+ .foregroundStyle(Color.ShareSheet.text)
+ }
+ }
+ .tint(Color.Accent.red)
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.vertical, Design.Spacing.medium)
+ }
+}
+
+// MARK: - Track Share Section
+
+private struct TrackShareSection: View {
let action: () -> Void
var body: some View {
Button(action: action) {
- ActionRowContent(
- title: String.localized("Track this share"),
- subtitle: String.localized("Record who received your card"),
- systemImage: "person.badge.plus"
- )
+ HStack(spacing: Design.Spacing.medium) {
+ Image(systemName: "person.badge.plus")
+ .font(.title3)
+ .foregroundStyle(Color.Accent.red)
+
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text("Track this share")
+ .font(.headline)
+ .foregroundStyle(Color.ShareSheet.text)
+
+ Text("Record who received your card")
+ .font(.caption)
+ .foregroundStyle(Color.ShareSheet.secondaryText)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .foregroundStyle(Color.ShareSheet.secondaryText)
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.ShareSheet.cardBackground)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
.buttonStyle(.plain)
.accessibilityHint(String.localized("Opens a form to record who you shared your card with"))
}
}
-// MARK: - Share Option Row
+// MARK: - Empty State
-private enum ShareOptionRow {
- static func link(title: String, systemImage: String, url: URL) -> some View {
+private struct EmptyShareState: View {
+ var body: some View {
+ VStack(spacing: Design.Spacing.large) {
+ Image(systemName: "rectangle.on.rectangle.slash")
+ .font(.system(size: Design.BaseFontSize.display))
+ .foregroundStyle(Color.ShareSheet.secondaryText)
+
+ Text("No card selected")
+ .font(.headline)
+ .foregroundStyle(Color.ShareSheet.text)
+
+ Text("Choose a card in the My Cards tab to start sharing.")
+ .font(.subheadline)
+ .foregroundStyle(Color.ShareSheet.secondaryText)
+ .multilineTextAlignment(.center)
+ }
+ .padding(Design.Spacing.xLarge)
+ }
+}
+
+// MARK: - Share Option Button
+
+private enum ShareOptionButton {
+ static func link(
+ title: String,
+ systemImage: String,
+ iconColor: Color = Color.ShareSheet.secondaryText,
+ url: URL
+ ) -> some View {
Link(destination: url) {
- RowContent(title: title, systemImage: systemImage)
+ RowContent(title: title, systemImage: systemImage, iconColor: iconColor)
}
.buttonStyle(.plain)
}
static func share(title: String, systemImage: String, item: URL) -> some View {
ShareLink(item: item) {
- RowContent(title: title, systemImage: systemImage)
+ RowContent(title: title, systemImage: systemImage, iconColor: Color.ShareSheet.secondaryText)
}
.buttonStyle(.plain)
}
static func action(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
- RowContent(title: title, systemImage: systemImage)
+ RowContent(title: title, systemImage: systemImage, iconColor: Color.ShareSheet.secondaryText)
}
.buttonStyle(.plain)
}
@@ -207,24 +302,23 @@ private enum ShareOptionRow {
private struct RowContent: View {
let title: String
let systemImage: String
+ let iconColor: Color
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
- .foregroundStyle(Color.Accent.red)
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .background(Color.AppBackground.accent)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ .font(.body)
+ .foregroundStyle(iconColor)
+ .frame(width: Design.Spacing.xLarge)
+
Text(title)
- .foregroundStyle(Color.Text.primary)
+ .foregroundStyle(Color.ShareSheet.text)
+
Spacer()
- Image(systemName: "chevron.right")
- .foregroundStyle(Color.Text.secondary)
}
- .padding(.horizontal, Design.Spacing.medium)
- .padding(.vertical, Design.Spacing.small)
- .background(Color.AppBackground.base)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.vertical, Design.Spacing.medium)
+ .contentShape(.rect)
}
}
diff --git a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift
new file mode 100644
index 0000000..cb7a80b
--- /dev/null
+++ b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift
@@ -0,0 +1,261 @@
+import SwiftUI
+import Bedrock
+
+/// Sheet for adding or editing a contact field value
+struct ContactFieldEditorSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let fieldType: ContactFieldType
+ let initialValue: String
+ let initialTitle: String
+ let onSave: (String, String) -> Void
+ let onDelete: (() -> Void)?
+
+ @State private var value: String
+ @State private var title: String
+
+ init(
+ fieldType: ContactFieldType,
+ initialValue: String = "",
+ initialTitle: String = "",
+ onSave: @escaping (String, String) -> Void,
+ onDelete: (() -> Void)? = nil
+ ) {
+ self.fieldType = fieldType
+ self.initialValue = initialValue
+ self.initialTitle = initialTitle
+ self.onSave = onSave
+ self.onDelete = onDelete
+ _value = State(initialValue: initialValue)
+ _title = State(initialValue: initialTitle)
+ }
+
+ private var isValid: Bool {
+ !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ private var isEditing: Bool {
+ !initialValue.isEmpty
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ // Header with icon
+ FieldHeaderView(fieldType: fieldType)
+
+ Divider()
+
+ // Form content
+ VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
+ // Value field
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text(fieldType.valueLabel)
+ .font(.subheadline)
+ .foregroundStyle(Color.Text.primary)
+
+ TextField(fieldType.valuePlaceholder, text: $value)
+ .keyboardType(fieldType.keyboardType)
+ .textInputAutocapitalization(fieldType.autocapitalization)
+ .textContentType(textContentType)
+
+ Divider()
+ }
+
+ // Title field
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text("Title (optional)")
+ .font(.subheadline)
+ .foregroundStyle(Color.Text.primary)
+
+ TextField(String(localized: "e.g. Work, Personal"), text: $title)
+
+ Divider()
+
+ // Suggestions
+ if !fieldType.titleSuggestions.isEmpty {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text("Here are some suggestions for your title:")
+ .font(.caption)
+ .foregroundStyle(Color.Text.secondary)
+
+ FlowLayout(spacing: Design.Spacing.small) {
+ ForEach(fieldType.titleSuggestions, id: \.self) { suggestion in
+ SuggestionChip(text: suggestion) {
+ title = suggestion
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Spacer()
+
+ // Delete button for editing
+ if isEditing, let onDelete {
+ Button(role: .destructive) {
+ onDelete()
+ dismiss()
+ } label: {
+ HStack {
+ Spacer()
+ Label("Delete Field", systemImage: "trash")
+ Spacer()
+ }
+ .padding(Design.Spacing.medium)
+ .background(Color.Accent.red.opacity(Design.Opacity.subtle))
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(Color.Accent.red)
+ }
+ }
+ .padding(Design.Spacing.large)
+ }
+ .background(Color.AppBackground.base)
+ .navigationTitle(isEditing ? "Edit \(fieldType.displayName)" : "Add \(fieldType.displayName)")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "chevron.left")
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title)
+ dismiss()
+ }
+ .disabled(!isValid)
+ }
+ }
+ }
+ }
+
+ private var textContentType: UITextContentType? {
+ switch fieldType.id {
+ case "phone": return .telephoneNumber
+ case "email": return .emailAddress
+ case "website": return .URL
+ case "address": return .fullStreetAddress
+ default: return .URL
+ }
+ }
+}
+
+// MARK: - Field Header
+
+private struct FieldHeaderView: View {
+ let fieldType: ContactFieldType
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.medium) {
+ Circle()
+ .fill(fieldType.iconColor)
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .overlay(
+ Image(systemName: fieldType.systemImage)
+ .font(.title3)
+ .foregroundStyle(.white)
+ )
+
+ Text(fieldType.displayName)
+ .font(.headline)
+ .foregroundStyle(Color.Text.primary)
+
+ Spacer()
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
+
+// MARK: - Suggestion Chip
+
+private struct SuggestionChip: View {
+ let text: String
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(text)
+ .font(.subheadline)
+ .padding(.horizontal, Design.Spacing.medium)
+ .padding(.vertical, Design.Spacing.small)
+ .background(Color.AppBackground.elevated)
+ .clipShape(.capsule)
+ .overlay(
+ Capsule()
+ .stroke(Color.Text.secondary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+// MARK: - Flow Layout
+
+private struct FlowLayout: Layout {
+ var spacing: CGFloat = 8
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let result = layout(subviews: subviews, proposal: proposal)
+ return result.size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = layout(subviews: subviews, proposal: proposal)
+
+ for (index, position) in result.positions.enumerated() {
+ subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
+ }
+ }
+
+ private func layout(subviews: Subviews, proposal: ProposedViewSize) -> (size: CGSize, positions: [CGPoint]) {
+ let maxWidth = proposal.width ?? .infinity
+ var positions: [CGPoint] = []
+ var currentX: CGFloat = 0
+ var currentY: CGFloat = 0
+ var lineHeight: CGFloat = 0
+ var maxX: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+
+ if currentX + size.width > maxWidth && currentX > 0 {
+ currentX = 0
+ currentY += lineHeight + spacing
+ lineHeight = 0
+ }
+
+ positions.append(CGPoint(x: currentX, y: currentY))
+ lineHeight = max(lineHeight, size.height)
+ currentX += size.width + spacing
+ maxX = max(maxX, currentX)
+ }
+
+ return (CGSize(width: maxX, height: currentY + lineHeight), positions)
+ }
+}
+
+#Preview("Add Email") {
+ ContactFieldEditorSheet(fieldType: .email) { value, title in
+ print("Saved: \(value), \(title)")
+ }
+}
+
+#Preview("Edit LinkedIn") {
+ ContactFieldEditorSheet(
+ fieldType: .linkedIn,
+ initialValue: "linkedin.com/in/mattbruce",
+ initialTitle: "Connect with me"
+ ) { value, title in
+ print("Saved: \(value), \(title)")
+ } onDelete: {
+ print("Deleted")
+ }
+}
diff --git a/README.md b/README.md
index 6e6ad2b..23156a3 100644
--- a/README.md
+++ b/README.md
@@ -12,26 +12,32 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
### My Cards
-- Create and browse multiple cards in a carousel
+- Create and browse multiple cards in a compact carousel view
- Set a default card for sharing
-- Preview bold card styles inspired by modern design
+- **Modern card design**: Banner with company logo, overlapping profile photo, clean contact rows
- **Profile photos**: Add a photo from your library or use an icon
-- **Rich profiles**: Pronouns, bio, social media links, custom URLs
+- **Company logos**: Upload a logo to display on your card's banner
+- **Rich profiles**: First/middle/last name, pronouns, headline, bio, accreditations
### Share
-- QR code display for vCard payloads
-- Share options: copy link, SMS, email, WhatsApp, LinkedIn
+- **Dark-themed share sheet**: Sleek QR code display with prominent sharing options
+- Share options: copy link, SMS, email, WhatsApp, LinkedIn, and more
+- **Offline sharing toggle**: Share your card without internet connection
- **Track shares**: Record who received your card and when
- Placeholder actions for Apple Wallet and NFC (alerts included)
### Customize
+- **Horizontal color picker**: Scrollable theme swatches for quick selection
- Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet)
-- Layout picker for stacked, split, or photo style
-- **Edit all card details**: Name, role, company, email, phone, website, location
-- **Social media links**: LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub
+- **Expandable name section**: First, middle, last, suffix, preferred name
+- **Edit all card details**: Name, role, department, company, headline, email (with label), phone (with label & extension), website, location
+- **Social media links**: LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub, Threads, Telegram
+- **Payment links**: Venmo, Cash App
- **Custom links**: Add up to 2 custom URLs with titles
+- **Suggestion chips**: Quick-fill suggestions for social link titles
+- **Live preview**: "Preview card" button to see changes before saving
- **Delete cards** you no longer need
### Contacts