From d4908a02d500002cb760f218dca044455fc2b5b7 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 8 Jan 2026 21:28:34 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../SocialSymbols/Contents.json | 6 + .../SocialSymbols/bluesky/Contents.json | 6 + .../bluesky/bluesky.symbolset/Contents.json | 12 + .../bluesky/bluesky.symbolset/bluesky.svg | 100 ++ .../SocialSymbols/discord/Contents.json | 6 + .../discord.fill.symbolset/Contents.json | 12 + .../discord.fill.symbolset/discord.fill.svg | 200 +++ .../discord/discord.symbolset/Contents.json | 12 + .../discord/discord.symbolset/discord.svg | 199 +++ .../SocialSymbols/discourse/Contents.json | 6 + .../discourse.fill.symbolset/Contents.json | 12 + .../discourse.fill.svg | 140 ++ .../SocialSymbols/facebook/Contents.json | 6 + .../facebook/facebook.symbolset/Contents.json | 12 + .../facebook/facebook.symbolset/facebook.svg | 98 ++ .../SocialSymbols/github/Contents.json | 6 + .../github.fill.symbolset/Contents.json | 12 + .../github.fill.symbolset/github.fill.svg | 167 ++ .../SocialSymbols/instagram/Contents.json | 6 + .../instagram.symbolset/Contents.json | 12 + .../instagram.symbolset/instagram.svg | 199 +++ .../SocialSymbols/ko-fi/Contents.json | 6 + .../ko-fi/ko-fi.symbolset/Contents.json | 12 + .../ko-fi/ko-fi.symbolset/ko-fi.svg | 157 ++ .../SocialSymbols/linkedin/Contents.json | 6 + .../linkedin/linkedin.symbolset/Contents.json | 12 + .../linkedin/linkedin.symbolset/linkedin.svg | 108 ++ .../SocialSymbols/mastodon/Contents.json | 6 + .../Contents.json | 12 + .../mastodon.clean.fill.svg | 200 +++ .../mastodon.clean.symbolset/Contents.json | 12 + .../mastodon.clean.svg | 200 +++ .../mastodon.fill.symbolset/Contents.json | 12 + .../mastodon.fill.symbolset/mastodon.fill.svg | 200 +++ .../mastodon/mastodon.symbolset/Contents.json | 12 + .../mastodon/mastodon.symbolset/mastodon.svg | 200 +++ .../SocialSymbols/matrix/Contents.json | 6 + .../matrix/matrix.symbolset/Contents.json | 12 + .../matrix/matrix.symbolset/matrix.svg | 107 ++ .../SocialSymbols/microblog/Contents.json | 6 + .../microblog.symbolset/Contents.json | 12 + .../microblog.symbolset/microblog.svg | 107 ++ .../SocialSymbols/patreon/Contents.json | 6 + .../patreon.fill.symbolset/Contents.json | 12 + .../patreon.fill.symbolset/patreon.fill.svg | 157 ++ .../SocialSymbols/reddit/Contents.json | 6 + .../reddit.fill.symbolset/Contents.json | 12 + .../reddit.fill.symbolset/reddit.fill.svg | 200 +++ .../reddit/reddit.symbolset/Contents.json | 12 + .../reddit/reddit.symbolset/reddit.svg | 200 +++ .../SocialSymbols/slack/Contents.json | 6 + .../slack/slack.symbolset/Contents.json | 12 + .../slack/slack.symbolset/slack.svg | 121 ++ .../SocialSymbols/telegram/Contents.json | 6 + .../telegram/telegram.symbolset/Contents.json | 12 + .../telegram/telegram.symbolset/telegram.svg | 200 +++ .../SocialSymbols/threads/Contents.json | 6 + .../threads/threads.symbolset/Contents.json | 12 + .../threads/threads.symbolset/threads.svg | 96 ++ .../SocialSymbols/tiktok/Contents.json | 6 + .../tiktok-official.symbolset/Contents.json | 12 + .../tiktok-official.svg | 114 ++ .../tiktok/tiktok.symbolset/Contents.json | 12 + .../tiktok/tiktok.symbolset/tiktok.svg | 100 ++ .../SocialSymbols/twitch/Contents.json | 6 + .../twitch/twitch.symbolset/Contents.json | 12 + .../twitch/twitch.symbolset/twitch.svg | 230 +++ .../SocialSymbols/twitter/Contents.json | 6 + .../twitter/tweetie.symbolset/Contents.json | 12 + .../twitter/tweetie.symbolset/tweetie.svg | 167 ++ .../twitter/twitter.symbolset/Contents.json | 12 + .../twitter/twitter.symbolset/twitter.svg | 167 ++ .../twitter/x-twitter.symbolset/Contents.json | 12 + .../twitter/x-twitter.symbolset/x-twitter.svg | 100 ++ .../SocialSymbols/youtube/Contents.json | 6 + .../youtube.fill.symbolset/Contents.json | 12 + .../youtube.fill.symbolset/youtube.fill.svg | 200 +++ .../youtube/youtube.symbolset/Contents.json | 12 + .../youtube/youtube.symbolset/youtube.svg | 200 +++ BusinessCard/BusinessCardApp.swift | 2 +- BusinessCard/Design/DesignConstants.swift | 36 +- BusinessCard/Models/BusinessCard.swift | 211 ++- BusinessCard/Models/Contact.swift | 26 + BusinessCard/Models/ContactField.swift | 85 + BusinessCard/Models/ContactFieldType.swift | 641 ++++++++ BusinessCard/Resources/Localizable.xcstrings | 174 +- BusinessCard/Views/BusinessCardView.swift | 389 +++-- BusinessCard/Views/CardCarouselView.swift | 4 +- BusinessCard/Views/CardEditorView.swift | 1421 ++++++++++++++--- .../Components/AddedContactFieldsView.swift | 123 ++ .../Components/ContactFieldPickerView.swift | 74 + .../Components/ContactFieldsManagerView.swift | 87 + BusinessCard/Views/ShareCardView.swift | 302 ++-- .../Sheets/ContactFieldEditorSheet.swift | 261 +++ README.md | 22 +- 95 files changed, 8425 insertions(+), 541 deletions(-) create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/bluesky/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/bluesky/bluesky.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/bluesky/bluesky.symbolset/bluesky.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/discord/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.fill.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.fill.symbolset/discord.fill.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/discord/discord.symbolset/discord.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/discourse/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/discourse/discourse.fill.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/discourse/discourse.fill.symbolset/discourse.fill.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/facebook/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/facebook/facebook.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/facebook/facebook.symbolset/facebook.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/github/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/github/github.fill.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/github/github.fill.symbolset/github.fill.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/instagram/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/instagram/instagram.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/instagram/instagram.symbolset/instagram.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/ko-fi.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/ko-fi/ko-fi.symbolset/ko-fi.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/linkedin/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/linkedin/linkedin.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/linkedin/linkedin.symbolset/linkedin.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.fill.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.fill.symbolset/mastodon.clean.fill.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.clean.symbolset/mastodon.clean.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.fill.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.fill.symbolset/mastodon.fill.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/mastodon/mastodon.symbolset/mastodon.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/matrix/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/matrix/matrix.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/matrix/matrix.symbolset/matrix.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/microblog/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/microblog/microblog.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/microblog/microblog.symbolset/microblog.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/patreon/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/patreon/patreon.fill.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/patreon/patreon.fill.symbolset/patreon.fill.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/reddit/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.fill.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.fill.symbolset/reddit.fill.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/reddit/reddit.symbolset/reddit.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/slack/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/slack/slack.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/slack/slack.symbolset/slack.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/telegram/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/telegram/telegram.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/telegram/telegram.symbolset/telegram.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/threads/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/threads/threads.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/threads/threads.symbolset/threads.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/tiktok/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok-official.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok-official.symbolset/tiktok-official.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/tiktok/tiktok.symbolset/tiktok.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitch/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitch/twitch.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitch/twitch.symbolset/twitch.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitter/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitter/tweetie.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitter/tweetie.symbolset/tweetie.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitter/twitter.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitter/twitter.symbolset/twitter.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitter/x-twitter.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/twitter/x-twitter.symbolset/x-twitter.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/youtube/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/youtube.fill.svg create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/Contents.json create mode 100644 BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/youtube.svg create mode 100644 BusinessCard/Models/ContactField.swift create mode 100644 BusinessCard/Models/ContactFieldType.swift create mode 100644 BusinessCard/Views/Components/AddedContactFieldsView.swift create mode 100644 BusinessCard/Views/Components/ContactFieldPickerView.swift create mode 100644 BusinessCard/Views/Components/ContactFieldsManagerView.swift create mode 100644 BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.5.0 + Requires Xcode 15 or greater + Generated from bluesky + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from discord.fill + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from discord + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from discours.fill.2 + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from facebook + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from github.fill + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from instagram + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from Ko-fi + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from linkedin + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from mastodon.clean.fill + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from mastodon.clean + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from mastodon.fill + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from mastodon + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from matrix + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.5.0 + Requires Xcode 15 or greater + Generated from microblog + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from Patreon + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from reddit.fill + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from reddit + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.5.0 + Requires Xcode 15 or greater + Generated from slack + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from telegram + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from threads + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.5.0 + Requires Xcode 15 or greater + Generated from tiktok-official.3 + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.5.0 + Requires Xcode 15 or greater + Generated from tiktok + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from twitch + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from tweetie + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from twitter + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.5.0 + Requires Xcode 15 or greater + Generated from x-twitter + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from youtube.fill + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from youtube + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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