Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
6ee74c422b
commit
6d9c956c06
@ -9,13 +9,6 @@ final class BusinessCard {
|
|||||||
var role: String
|
var role: String
|
||||||
var company: String
|
var company: String
|
||||||
var label: 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
|
var isDefault: Bool
|
||||||
var themeName: String
|
var themeName: String
|
||||||
var layoutStyleRawValue: String
|
var layoutStyleRawValue: String
|
||||||
@ -37,27 +30,12 @@ final class BusinessCard {
|
|||||||
var bio: String
|
var bio: String
|
||||||
var accreditations: 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
|
|
||||||
var customLink2URL: String
|
|
||||||
|
|
||||||
// Profile photo stored as Data (JPEG)
|
// Profile photo stored as Data (JPEG)
|
||||||
@Attribute(.externalStorage) var photoData: Data?
|
@Attribute(.externalStorage) var photoData: Data?
|
||||||
|
|
||||||
|
// Cover photo for banner background stored as Data (JPEG)
|
||||||
|
@Attribute(.externalStorage) var coverPhotoData: Data?
|
||||||
|
|
||||||
// Company logo stored as Data (PNG)
|
// Company logo stored as Data (PNG)
|
||||||
@Attribute(.externalStorage) var logoData: Data?
|
@Attribute(.externalStorage) var logoData: Data?
|
||||||
|
|
||||||
@ -71,13 +49,6 @@ final class BusinessCard {
|
|||||||
role: String = "",
|
role: String = "",
|
||||||
company: String = "",
|
company: String = "",
|
||||||
label: String = "Work",
|
label: String = "Work",
|
||||||
email: String = "",
|
|
||||||
emailLabel: String = "Work",
|
|
||||||
phone: String = "",
|
|
||||||
phoneLabel: String = "Cell",
|
|
||||||
phoneExtension: String = "",
|
|
||||||
website: String = "",
|
|
||||||
location: String = "",
|
|
||||||
isDefault: Bool = false,
|
isDefault: Bool = false,
|
||||||
themeName: String = "Coral",
|
themeName: String = "Coral",
|
||||||
layoutStyleRawValue: String = "stacked",
|
layoutStyleRawValue: String = "stacked",
|
||||||
@ -96,21 +67,8 @@ final class BusinessCard {
|
|||||||
headline: String = "",
|
headline: String = "",
|
||||||
bio: String = "",
|
bio: String = "",
|
||||||
accreditations: 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,
|
||||||
|
coverPhotoData: Data? = nil,
|
||||||
logoData: Data? = nil
|
logoData: Data? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@ -118,13 +76,6 @@ final class BusinessCard {
|
|||||||
self.role = role
|
self.role = role
|
||||||
self.company = company
|
self.company = company
|
||||||
self.label = label
|
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
|
self.isDefault = isDefault
|
||||||
self.themeName = themeName
|
self.themeName = themeName
|
||||||
self.layoutStyleRawValue = layoutStyleRawValue
|
self.layoutStyleRawValue = layoutStyleRawValue
|
||||||
@ -143,21 +94,8 @@ final class BusinessCard {
|
|||||||
self.headline = headline
|
self.headline = headline
|
||||||
self.bio = bio
|
self.bio = bio
|
||||||
self.accreditations = accreditations
|
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.photoData = photoData
|
||||||
|
self.coverPhotoData = coverPhotoData
|
||||||
self.logoData = logoData
|
self.logoData = logoData
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,29 +159,6 @@ final class BusinessCard {
|
|||||||
orderedContactFields.filter { $0.typeId == typeId }
|
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 {
|
|
||||||
// Check new array first
|
|
||||||
let socialTypes: Set<String> = ["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)
|
/// Returns accreditations as an array (stored as comma-separated string)
|
||||||
var accreditationsList: [String] {
|
var accreditationsList: [String] {
|
||||||
get {
|
get {
|
||||||
@ -265,45 +180,14 @@ final class BusinessCard {
|
|||||||
|
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
|
|
||||||
// Prefix (Dr, Mr, Ms, etc.)
|
if !prefix.isEmpty { parts.append(prefix) }
|
||||||
if !prefix.isEmpty {
|
if !firstName.isEmpty { parts.append(firstName) }
|
||||||
parts.append(prefix)
|
if !preferredName.isEmpty { parts.append("\"\(preferredName)\"") }
|
||||||
}
|
if !middleName.isEmpty { parts.append(middleName) }
|
||||||
|
if !lastName.isEmpty { parts.append(lastName) }
|
||||||
// First name
|
if !suffix.isEmpty { parts.append(suffix) }
|
||||||
if !firstName.isEmpty {
|
if !maidenName.isEmpty { parts.append("(\(maidenName))") }
|
||||||
parts.append(firstName)
|
if !pronouns.isEmpty { parts.append("(\(pronouns))") }
|
||||||
}
|
|
||||||
|
|
||||||
// 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: " ")
|
return parts.joined(separator: " ")
|
||||||
}
|
}
|
||||||
@ -322,12 +206,6 @@ final class BusinessCard {
|
|||||||
return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ")
|
return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the card has custom links
|
|
||||||
var hasCustomLinks: Bool {
|
|
||||||
(!customLink1Title.isEmpty && !customLink1URL.isEmpty) ||
|
|
||||||
(!customLink2Title.isEmpty && !customLink2URL.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
var vCardPayload: String {
|
var vCardPayload: String {
|
||||||
var lines = [
|
var lines = [
|
||||||
"BEGIN:VCARD",
|
"BEGIN:VCARD",
|
||||||
@ -335,13 +213,12 @@ final class BusinessCard {
|
|||||||
]
|
]
|
||||||
|
|
||||||
// N: Structured name - REQUIRED for proper contact import
|
// N: Structured name - REQUIRED for proper contact import
|
||||||
// Format: LastName;FirstName;MiddleName;Prefix;Suffix
|
|
||||||
let structuredName = [lastName, firstName, middleName, prefix, suffix]
|
let structuredName = [lastName, firstName, middleName, prefix, suffix]
|
||||||
.map { escapeVCardValue($0) }
|
.map { escapeVCardValue($0) }
|
||||||
.joined(separator: ";")
|
.joined(separator: ";")
|
||||||
lines.append("N:\(structuredName)")
|
lines.append("N:\(structuredName)")
|
||||||
|
|
||||||
// FN: Formatted name - use simple name or display name
|
// FN: Formatted name
|
||||||
let formattedName = simpleName.isEmpty ? displayName : simpleName
|
let formattedName = simpleName.isEmpty ? displayName : simpleName
|
||||||
lines.append("FN:\(escapeVCardValue(formattedName))")
|
lines.append("FN:\(escapeVCardValue(formattedName))")
|
||||||
|
|
||||||
@ -350,7 +227,7 @@ final class BusinessCard {
|
|||||||
lines.append("NICKNAME:\(escapeVCardValue(preferredName))")
|
lines.append("NICKNAME:\(escapeVCardValue(preferredName))")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ORG: Organization - can include department
|
// ORG: Organization
|
||||||
if !company.isEmpty {
|
if !company.isEmpty {
|
||||||
if !department.isEmpty {
|
if !department.isEmpty {
|
||||||
lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))")
|
lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))")
|
||||||
@ -364,7 +241,7 @@ final class BusinessCard {
|
|||||||
lines.append("TITLE:\(escapeVCardValue(role))")
|
lines.append("TITLE:\(escapeVCardValue(role))")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contact fields from the new array (preferred)
|
// Contact fields from the array
|
||||||
for field in orderedContactFields {
|
for field in orderedContactFields {
|
||||||
let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !value.isEmpty else { continue }
|
guard !value.isEmpty else { continue }
|
||||||
@ -430,83 +307,18 @@ final class BusinessCard {
|
|||||||
let label = field.title.isEmpty ? "OTHER" : field.title.uppercased()
|
let label = field.title.isEmpty ? "OTHER" : field.title.uppercased()
|
||||||
lines.append("URL;TYPE=\(label):\(escapeVCardValue(value))")
|
lines.append("URL;TYPE=\(label):\(escapeVCardValue(value))")
|
||||||
default:
|
default:
|
||||||
// For unknown types, add as URL if it looks like a URL
|
|
||||||
if value.contains("://") || value.contains(".") {
|
if value.contains("://") || value.contains(".") {
|
||||||
lines.append("URL:\(escapeVCardValue(value))")
|
lines.append("URL:\(escapeVCardValue(value))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to legacy properties if no contact fields exist
|
|
||||||
if orderedContactFields.isEmpty {
|
|
||||||
if !phone.isEmpty {
|
|
||||||
let typeLabel = phoneLabel.isEmpty ? "CELL" : phoneLabel.uppercased()
|
|
||||||
lines.append("TEL;TYPE=\(typeLabel):\(escapeVCardValue(phone))")
|
|
||||||
}
|
|
||||||
if !email.isEmpty {
|
|
||||||
let typeLabel = emailLabel.isEmpty ? "WORK" : emailLabel.uppercased()
|
|
||||||
lines.append("EMAIL;TYPE=\(typeLabel):\(escapeVCardValue(email))")
|
|
||||||
}
|
|
||||||
if !website.isEmpty {
|
|
||||||
lines.append("URL:\(escapeVCardValue(website))")
|
|
||||||
}
|
|
||||||
if !location.isEmpty {
|
|
||||||
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(location));;;;")
|
|
||||||
}
|
|
||||||
if !linkedIn.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(linkedIn))")
|
|
||||||
}
|
|
||||||
if !twitter.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(escapeVCardValue(twitter))")
|
|
||||||
}
|
|
||||||
if !instagram.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(escapeVCardValue(instagram))")
|
|
||||||
}
|
|
||||||
if !facebook.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=facebook:\(escapeVCardValue(facebook))")
|
|
||||||
}
|
|
||||||
if !tiktok.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=tiktok:\(escapeVCardValue(tiktok))")
|
|
||||||
}
|
|
||||||
if !github.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=github:\(escapeVCardValue(github))")
|
|
||||||
}
|
|
||||||
if !threads.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=threads:\(escapeVCardValue(threads))")
|
|
||||||
}
|
|
||||||
if !telegram.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=telegram:\(escapeVCardValue(telegram))")
|
|
||||||
}
|
|
||||||
if !venmo.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=venmo:\(escapeVCardValue(venmo))")
|
|
||||||
}
|
|
||||||
if !cashApp.isEmpty {
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=cashapp:\(escapeVCardValue(cashApp))")
|
|
||||||
}
|
|
||||||
if !customLink1URL.isEmpty {
|
|
||||||
let label = customLink1Title.isEmpty ? "OTHER" : customLink1Title.uppercased()
|
|
||||||
lines.append("URL;TYPE=\(label):\(escapeVCardValue(customLink1URL))")
|
|
||||||
}
|
|
||||||
if !customLink2URL.isEmpty {
|
|
||||||
let label = customLink2Title.isEmpty ? "OTHER" : customLink2Title.uppercased()
|
|
||||||
lines.append("URL;TYPE=\(label):\(escapeVCardValue(customLink2URL))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Bio, headline, and accreditations
|
// NOTE: Bio, headline, and accreditations
|
||||||
var notes: [String] = []
|
var notes: [String] = []
|
||||||
if !headline.isEmpty {
|
if !headline.isEmpty { notes.append(headline) }
|
||||||
notes.append(headline)
|
if !bio.isEmpty { notes.append(bio) }
|
||||||
}
|
if !accreditations.isEmpty { notes.append("Credentials: \(accreditations)") }
|
||||||
if !bio.isEmpty {
|
if !pronouns.isEmpty { notes.append("Pronouns: \(pronouns)") }
|
||||||
notes.append(bio)
|
|
||||||
}
|
|
||||||
if !accreditations.isEmpty {
|
|
||||||
notes.append("Credentials: \(accreditations)")
|
|
||||||
}
|
|
||||||
if !pronouns.isEmpty {
|
|
||||||
notes.append("Pronouns: \(pronouns)")
|
|
||||||
}
|
|
||||||
if !notes.isEmpty {
|
if !notes.isEmpty {
|
||||||
lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))")
|
lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))")
|
||||||
}
|
}
|
||||||
@ -528,63 +340,65 @@ final class BusinessCard {
|
|||||||
extension BusinessCard {
|
extension BusinessCard {
|
||||||
@MainActor
|
@MainActor
|
||||||
static func createSamples(in context: ModelContext) {
|
static func createSamples(in context: ModelContext) {
|
||||||
let samples = [
|
// Sample 1: Property Developer
|
||||||
BusinessCard(
|
let sample1 = BusinessCard(
|
||||||
displayName: "Daniel Sullivan",
|
displayName: "Daniel Sullivan",
|
||||||
role: "Property Developer",
|
role: "Property Developer",
|
||||||
company: "WR Construction",
|
company: "WR Construction",
|
||||||
label: "Work",
|
label: "Work",
|
||||||
email: "daniel@wrconstruction.co",
|
|
||||||
phone: "+1 (214) 987-7810",
|
|
||||||
website: "wrconstruction.co",
|
|
||||||
location: "Dallas, TX",
|
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
themeName: "Coral",
|
themeName: "Coral",
|
||||||
layoutStyleRawValue: "split",
|
layoutStyleRawValue: "split",
|
||||||
avatarSystemName: "person.crop.circle",
|
avatarSystemName: "person.crop.circle",
|
||||||
pronouns: "he/him",
|
pronouns: "he/him",
|
||||||
bio: "Building the future of Dallas real estate",
|
bio: "Building the future of Dallas real estate"
|
||||||
linkedIn: "linkedin.com/in/danielsullivan"
|
)
|
||||||
),
|
context.insert(sample1)
|
||||||
BusinessCard(
|
sample1.addContactField(.email, value: "daniel@wrconstruction.co", title: "Work")
|
||||||
|
sample1.addContactField(.phone, value: "+1 (214) 987-7810", title: "Cell")
|
||||||
|
sample1.addContactField(.website, value: "wrconstruction.co", title: "")
|
||||||
|
sample1.addContactField(.address, value: "Dallas, TX", title: "Work")
|
||||||
|
sample1.addContactField(.linkedIn, value: "linkedin.com/in/danielsullivan", title: "")
|
||||||
|
|
||||||
|
// Sample 2: Creative Lead
|
||||||
|
let sample2 = BusinessCard(
|
||||||
displayName: "Maya Chen",
|
displayName: "Maya Chen",
|
||||||
role: "Creative Lead",
|
role: "Creative Lead",
|
||||||
company: "Signal Studio",
|
company: "Signal Studio",
|
||||||
label: "Creative",
|
label: "Creative",
|
||||||
email: "maya@signal.studio",
|
|
||||||
phone: "+1 (312) 404-2211",
|
|
||||||
website: "signal.studio",
|
|
||||||
location: "Chicago, IL",
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
themeName: "Midnight",
|
themeName: "Midnight",
|
||||||
layoutStyleRawValue: "stacked",
|
layoutStyleRawValue: "stacked",
|
||||||
avatarSystemName: "sparkles",
|
avatarSystemName: "sparkles",
|
||||||
pronouns: "she/her",
|
pronouns: "she/her",
|
||||||
bio: "Designing experiences that matter",
|
bio: "Designing experiences that matter"
|
||||||
twitter: "twitter.com/mayachen",
|
)
|
||||||
instagram: "instagram.com/mayachen.design"
|
context.insert(sample2)
|
||||||
),
|
sample2.addContactField(.email, value: "maya@signal.studio", title: "Work")
|
||||||
BusinessCard(
|
sample2.addContactField(.phone, value: "+1 (312) 404-2211", title: "Cell")
|
||||||
|
sample2.addContactField(.website, value: "signal.studio", title: "")
|
||||||
|
sample2.addContactField(.address, value: "Chicago, IL", title: "Work")
|
||||||
|
sample2.addContactField(.twitter, value: "twitter.com/mayachen", title: "")
|
||||||
|
sample2.addContactField(.instagram, value: "instagram.com/mayachen.design", title: "")
|
||||||
|
|
||||||
|
// Sample 3: DJ
|
||||||
|
let sample3 = BusinessCard(
|
||||||
displayName: "DJ Michaels",
|
displayName: "DJ Michaels",
|
||||||
role: "DJ",
|
role: "DJ",
|
||||||
company: "Live Sessions",
|
company: "Live Sessions",
|
||||||
label: "Music",
|
label: "Music",
|
||||||
email: "dj@livesessions.fm",
|
|
||||||
phone: "+1 (646) 222-3300",
|
|
||||||
website: "livesessions.fm",
|
|
||||||
location: "New York, NY",
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
themeName: "Ocean",
|
themeName: "Ocean",
|
||||||
layoutStyleRawValue: "photo",
|
layoutStyleRawValue: "photo",
|
||||||
avatarSystemName: "music.mic",
|
avatarSystemName: "music.mic",
|
||||||
bio: "Bringing the beats to your events",
|
bio: "Bringing the beats to your events"
|
||||||
instagram: "instagram.com/djmichaels",
|
|
||||||
tiktok: "tiktok.com/@djmichaels"
|
|
||||||
)
|
)
|
||||||
]
|
context.insert(sample3)
|
||||||
|
sample3.addContactField(.email, value: "dj@livesessions.fm", title: "Work")
|
||||||
for sample in samples {
|
sample3.addContactField(.phone, value: "+1 (646) 222-3300", title: "Cell")
|
||||||
context.insert(sample)
|
sample3.addContactField(.website, value: "livesessions.fm", title: "")
|
||||||
}
|
sample3.addContactField(.address, value: "New York, NY", title: "Work")
|
||||||
|
sample3.addContactField(.instagram, value: "instagram.com/djmichaels", title: "")
|
||||||
|
sample3.addContactField(.tiktok, value: "tiktok.com/@djmichaels", title: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,27 +11,19 @@ final class Contact {
|
|||||||
var lastSharedDate: Date
|
var lastSharedDate: Date
|
||||||
var cardLabel: String
|
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
|
// Contact annotations
|
||||||
var notes: String
|
var notes: String
|
||||||
var tags: String // Comma-separated tags
|
var tags: String // Comma-separated tags
|
||||||
var followUpDate: Date?
|
var followUpDate: Date?
|
||||||
var email: String
|
var email: String // Legacy single email (kept for migration/fallback)
|
||||||
var phone: String
|
var phone: String // Legacy single phone (kept for migration/fallback)
|
||||||
var metAt: String // Where you met this person
|
|
||||||
|
// Multiple contact fields (phones, emails, links with labels)
|
||||||
|
@Relationship(deleteRule: .cascade, inverse: \ContactField.contact)
|
||||||
|
var contactFields: [ContactField]?
|
||||||
|
|
||||||
// If this is a received card (scanned from someone else)
|
// If this is a received card (scanned from someone else)
|
||||||
var isReceivedCard: Bool
|
var isReceivedCard: Bool
|
||||||
var receivedCardData: String // vCard data if received
|
|
||||||
|
|
||||||
// Profile photo
|
// Profile photo
|
||||||
@Attribute(.externalStorage) var photoData: Data?
|
@Attribute(.externalStorage) var photoData: Data?
|
||||||
@ -44,22 +36,12 @@ final class Contact {
|
|||||||
avatarSystemName: String = "person.crop.circle",
|
avatarSystemName: String = "person.crop.circle",
|
||||||
lastSharedDate: Date = .now,
|
lastSharedDate: Date = .now,
|
||||||
cardLabel: String = "Work",
|
cardLabel: String = "Work",
|
||||||
prefix: String = "",
|
|
||||||
firstName: String = "",
|
|
||||||
middleName: String = "",
|
|
||||||
lastName: String = "",
|
|
||||||
suffix: String = "",
|
|
||||||
maidenName: String = "",
|
|
||||||
preferredName: String = "",
|
|
||||||
pronouns: String = "",
|
|
||||||
notes: String = "",
|
notes: String = "",
|
||||||
tags: String = "",
|
tags: String = "",
|
||||||
followUpDate: Date? = nil,
|
followUpDate: Date? = nil,
|
||||||
email: String = "",
|
email: String = "",
|
||||||
phone: String = "",
|
phone: String = "",
|
||||||
metAt: String = "",
|
|
||||||
isReceivedCard: Bool = false,
|
isReceivedCard: Bool = false,
|
||||||
receivedCardData: String = "",
|
|
||||||
photoData: Data? = nil
|
photoData: Data? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@ -69,22 +51,12 @@ final class Contact {
|
|||||||
self.avatarSystemName = avatarSystemName
|
self.avatarSystemName = avatarSystemName
|
||||||
self.lastSharedDate = lastSharedDate
|
self.lastSharedDate = lastSharedDate
|
||||||
self.cardLabel = cardLabel
|
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.notes = notes
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
self.followUpDate = followUpDate
|
self.followUpDate = followUpDate
|
||||||
self.email = email
|
self.email = email
|
||||||
self.phone = phone
|
self.phone = phone
|
||||||
self.metAt = metAt
|
|
||||||
self.isReceivedCard = isReceivedCard
|
self.isReceivedCard = isReceivedCard
|
||||||
self.receivedCardData = receivedCardData
|
|
||||||
self.photoData = photoData
|
self.photoData = photoData
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,6 +67,31 @@ final class Contact {
|
|||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns contact fields sorted by order index
|
||||||
|
var sortedContactFields: [ContactField] {
|
||||||
|
(contactFields ?? []).sorted { $0.orderIndex < $1.orderIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters contact fields by type ID
|
||||||
|
func fields(ofType typeId: String) -> [ContactField] {
|
||||||
|
sortedContactFields.filter { $0.typeId == typeId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all phone numbers
|
||||||
|
var phoneNumbers: [ContactField] {
|
||||||
|
fields(ofType: "phone")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all email addresses
|
||||||
|
var emailAddresses: [ContactField] {
|
||||||
|
fields(ofType: "email")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all links (website + custom links)
|
||||||
|
var links: [ContactField] {
|
||||||
|
sortedContactFields.filter { $0.typeId == "website" || $0.typeId == "customLink" }
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this contact has a follow-up reminder set
|
/// Whether this contact has a follow-up reminder set
|
||||||
var hasFollowUp: Bool {
|
var hasFollowUp: Bool {
|
||||||
followUpDate != nil
|
followUpDate != nil
|
||||||
@ -120,8 +117,7 @@ extension Contact {
|
|||||||
notes: "Met at the Austin fintech conference. Interested in property financing.",
|
notes: "Met at the Austin fintech conference. Interested in property financing.",
|
||||||
tags: "finance, potential client",
|
tags: "finance, potential client",
|
||||||
followUpDate: .now.addingTimeInterval(86400 * 7),
|
followUpDate: .now.addingTimeInterval(86400 * 7),
|
||||||
email: "kevin.lennox@globalbank.com",
|
email: "kevin.lennox@globalbank.com"
|
||||||
metAt: "Austin Fintech Conference 2026"
|
|
||||||
),
|
),
|
||||||
Contact(
|
Contact(
|
||||||
name: "Jenny Wright",
|
name: "Jenny Wright",
|
||||||
@ -132,8 +128,7 @@ extension Contact {
|
|||||||
cardLabel: "Creative",
|
cardLabel: "Creative",
|
||||||
notes: "Great portfolio. Could be a good hire or contractor.",
|
notes: "Great portfolio. Could be a good hire or contractor.",
|
||||||
tags: "designer, talent",
|
tags: "designer, talent",
|
||||||
email: "jenny@appfoundry.io",
|
email: "jenny@appfoundry.io"
|
||||||
metAt: "LinkedIn"
|
|
||||||
),
|
),
|
||||||
Contact(
|
Contact(
|
||||||
name: "Pip McDowell",
|
name: "Pip McDowell",
|
||||||
@ -164,8 +159,7 @@ extension Contact {
|
|||||||
lastSharedDate: .now.addingTimeInterval(-86400 * 7),
|
lastSharedDate: .now.addingTimeInterval(-86400 * 7),
|
||||||
cardLabel: "Press",
|
cardLabel: "Press",
|
||||||
notes: "Writing a piece on commercial real estate trends.",
|
notes: "Writing a piece on commercial real estate trends.",
|
||||||
tags: "press, media",
|
tags: "press, media"
|
||||||
metAt: "Industry panel"
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -176,7 +170,7 @@ extension Contact {
|
|||||||
|
|
||||||
/// Creates a contact from received vCard data
|
/// Creates a contact from received vCard data
|
||||||
static func fromVCard(_ vCardData: String) -> Contact {
|
static func fromVCard(_ vCardData: String) -> Contact {
|
||||||
let contact = Contact(isReceivedCard: true, receivedCardData: vCardData)
|
let contact = Contact(isReceivedCard: true)
|
||||||
|
|
||||||
// Parse vCard fields
|
// Parse vCard fields
|
||||||
let lines = vCardData.components(separatedBy: "\n")
|
let lines = vCardData.components(separatedBy: "\n")
|
||||||
|
|||||||
@ -23,6 +23,9 @@ final class ContactField {
|
|||||||
/// Parent business card (inverse relationship)
|
/// Parent business card (inverse relationship)
|
||||||
var card: BusinessCard?
|
var card: BusinessCard?
|
||||||
|
|
||||||
|
/// Parent contact (inverse relationship for contact fields)
|
||||||
|
var contact: Contact?
|
||||||
|
|
||||||
init(typeId: String, value: String = "", title: String = "", orderIndex: Int = 0) {
|
init(typeId: String, value: String = "", title: String = "", orderIndex: Int = 0) {
|
||||||
self.id = UUID()
|
self.id = UUID()
|
||||||
self.typeId = typeId
|
self.typeId = typeId
|
||||||
|
|||||||
@ -125,6 +125,7 @@ extension ContactFieldType {
|
|||||||
Dictionary(grouping: allCases, by: { $0.category })
|
Dictionary(grouping: allCases, by: { $0.category })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Contact
|
// MARK: - Contact
|
||||||
|
|
||||||
static let phone = ContactFieldType(
|
static let phone = ContactFieldType(
|
||||||
|
|||||||
@ -91,9 +91,6 @@
|
|||||||
},
|
},
|
||||||
"Are you sure you want to delete this contact?" : {
|
"Are you sure you want to delete this contact?" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Bio" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Calendly Link" : {
|
"Calendly Link" : {
|
||||||
|
|
||||||
@ -105,6 +102,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Card style" : {
|
"Card style" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -154,6 +152,9 @@
|
|||||||
},
|
},
|
||||||
"Choose a card in the My Cards tab to start sharing." : {
|
"Choose a card in the My Cards tab to start sharing." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Company" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Company Website" : {
|
"Company Website" : {
|
||||||
|
|
||||||
@ -163,6 +164,9 @@
|
|||||||
},
|
},
|
||||||
"Contact" : {
|
"Contact" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Contact Fields" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Cover photo" : {
|
"Cover photo" : {
|
||||||
|
|
||||||
@ -192,9 +196,6 @@
|
|||||||
},
|
},
|
||||||
"Create your first card" : {
|
"Create your first card" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Custom Links" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Customize your card" : {
|
"Customize your card" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -224,6 +225,9 @@
|
|||||||
},
|
},
|
||||||
"Delete Field" : {
|
"Delete Field" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Department" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Design and share polished digital business cards for every context." : {
|
"Design and share polished digital business cards for every context." : {
|
||||||
|
|
||||||
@ -246,14 +250,14 @@
|
|||||||
"Email or Username" : {
|
"Email or Username" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Ext." : {
|
"First Name" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Headline" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Here are some suggestions for your title:" : {
|
"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." : {
|
"Hold your phone near another device to share instantly. NFC setup is on the way." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -302,17 +306,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Label" : {
|
"Job Title" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Last Name" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Link" : {
|
"Link" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Location" : {
|
"Maiden Name" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Messaging" : {
|
"Messaging" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Middle Name" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"More..." : {
|
"More..." : {
|
||||||
|
|
||||||
@ -362,9 +372,6 @@
|
|||||||
},
|
},
|
||||||
"Personal details" : {
|
"Personal details" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Phone" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Phone Number" : {
|
"Phone Number" : {
|
||||||
|
|
||||||
@ -424,12 +431,21 @@
|
|||||||
},
|
},
|
||||||
"Portfolio" : {
|
"Portfolio" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Preferred Name" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Prefix (e.g. Dr., Mr., Ms.)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Preview card" : {
|
"Preview card" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Profile Link" : {
|
"Profile Link" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Pronouns (e.g. she/her)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"QR Code Scanned" : {
|
"QR Code Scanned" : {
|
||||||
|
|
||||||
@ -521,6 +537,9 @@
|
|||||||
},
|
},
|
||||||
"Social Media" : {
|
"Social Media" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Suffix (e.g. Jr., III)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Support & Funding" : {
|
"Support & Funding" : {
|
||||||
|
|
||||||
@ -530,9 +549,6 @@
|
|||||||
},
|
},
|
||||||
"Tap a field below to add it" : {
|
"Tap a field below to add it" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Tap to edit this accreditation" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tap to share" : {
|
"Tap to share" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -669,9 +685,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Website" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Website URL" : {
|
"Website URL" : {
|
||||||
|
|
||||||
|
|||||||
@ -10,25 +10,35 @@ struct WatchSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Syncs the given cards to the shared App Group for watchOS to read
|
/// Syncs the given cards to the shared App Group for watchOS to read
|
||||||
|
@MainActor
|
||||||
static func syncCards(_ cards: [BusinessCard]) {
|
static func syncCards(_ cards: [BusinessCard]) {
|
||||||
guard let defaults = sharedDefaults else { return }
|
guard let defaults = sharedDefaults else { return }
|
||||||
|
|
||||||
let syncableCards = cards.map { card in
|
let syncableCards = cards.map { card in
|
||||||
SyncableCard(
|
// Get first email, phone, website, location from contact fields
|
||||||
|
let email = card.firstContactField(ofType: "email")?.value ?? ""
|
||||||
|
let phone = card.firstContactField(ofType: "phone")?.value ?? ""
|
||||||
|
let website = card.firstContactField(ofType: "website")?.value ?? ""
|
||||||
|
let location = card.firstContactField(ofType: "address")?.value ?? ""
|
||||||
|
let linkedIn = card.firstContactField(ofType: "linkedIn")?.value ?? ""
|
||||||
|
let twitter = card.firstContactField(ofType: "twitter")?.value ?? ""
|
||||||
|
let instagram = card.firstContactField(ofType: "instagram")?.value ?? ""
|
||||||
|
|
||||||
|
return SyncableCard(
|
||||||
id: card.id,
|
id: card.id,
|
||||||
displayName: card.displayName,
|
displayName: card.displayName,
|
||||||
role: card.role,
|
role: card.role,
|
||||||
company: card.company,
|
company: card.company,
|
||||||
email: card.email,
|
email: email,
|
||||||
phone: card.phone,
|
phone: phone,
|
||||||
website: card.website,
|
website: website,
|
||||||
location: card.location,
|
location: location,
|
||||||
isDefault: card.isDefault,
|
isDefault: card.isDefault,
|
||||||
pronouns: card.pronouns,
|
pronouns: card.pronouns,
|
||||||
bio: card.bio,
|
bio: card.bio,
|
||||||
linkedIn: card.linkedIn,
|
linkedIn: linkedIn,
|
||||||
twitter: card.twitter,
|
twitter: twitter,
|
||||||
instagram: card.instagram
|
instagram: instagram
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -84,8 +84,8 @@ final class ContactsStore: ContactTracking {
|
|||||||
phone: String = "",
|
phone: String = "",
|
||||||
notes: String = "",
|
notes: String = "",
|
||||||
tags: String = "",
|
tags: String = "",
|
||||||
metAt: String = "",
|
followUpDate: Date? = nil,
|
||||||
followUpDate: Date? = nil
|
contactFields: [ContactField] = []
|
||||||
) {
|
) {
|
||||||
let contact = Contact(
|
let contact = Contact(
|
||||||
name: name,
|
name: name,
|
||||||
@ -96,10 +96,19 @@ final class ContactsStore: ContactTracking {
|
|||||||
tags: tags,
|
tags: tags,
|
||||||
followUpDate: followUpDate,
|
followUpDate: followUpDate,
|
||||||
email: email,
|
email: email,
|
||||||
phone: phone,
|
phone: phone
|
||||||
metAt: metAt
|
|
||||||
)
|
)
|
||||||
modelContext.insert(contact)
|
modelContext.insert(contact)
|
||||||
|
|
||||||
|
// Add contact fields if provided
|
||||||
|
if !contactFields.isEmpty {
|
||||||
|
for field in contactFields {
|
||||||
|
field.contact = contact
|
||||||
|
modelContext.insert(field)
|
||||||
|
}
|
||||||
|
contact.contactFields = contactFields
|
||||||
|
}
|
||||||
|
|
||||||
saveContext()
|
saveContext()
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,20 +37,30 @@ private struct CardBannerView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Gradient background
|
// Background: cover photo or gradient
|
||||||
|
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
|
||||||
|
Image(uiImage: uiImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(height: Design.CardSize.bannerHeight)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
// Fallback gradient
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [card.theme.primaryColor, card.theme.secondaryColor],
|
colors: [card.theme.primaryColor, card.theme.secondaryColor],
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Company logo
|
// Company logo overlay
|
||||||
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
|
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(height: Design.CardSize.logoSize)
|
.frame(height: Design.CardSize.logoSize)
|
||||||
} else if !card.company.isEmpty {
|
} else if card.coverPhotoData == nil && !card.company.isEmpty {
|
||||||
|
// Only show company initial if no cover photo and no logo
|
||||||
Text(card.company.prefix(1).uppercased())
|
Text(card.company.prefix(1).uppercased())
|
||||||
.font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded))
|
.font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
|
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
|
||||||
@ -173,10 +183,6 @@ private struct ContactFieldsListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy properties fallback (for backward compatibility)
|
|
||||||
if card.orderedContactFields.isEmpty {
|
|
||||||
LegacyContactDetailsView(card: card)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -226,144 +232,6 @@ private struct ContactFieldRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy view for cards that haven't migrated to the new contact fields array
|
|
||||||
private struct LegacyContactDetailsView: View {
|
|
||||||
@Environment(\.openURL) private var openURL
|
|
||||||
let card: BusinessCard
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
||||||
if !card.email.isEmpty {
|
|
||||||
LegacyContactRowView(
|
|
||||||
fieldType: .email,
|
|
||||||
value: card.email,
|
|
||||||
label: card.emailLabel
|
|
||||||
) {
|
|
||||||
if let url = URL(string: "mailto:\(card.email)") {
|
|
||||||
openURL(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !card.phone.isEmpty {
|
|
||||||
let phoneDisplay = card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)"
|
|
||||||
LegacyContactRowView(
|
|
||||||
fieldType: .phone,
|
|
||||||
value: phoneDisplay,
|
|
||||||
label: card.phoneLabel
|
|
||||||
) {
|
|
||||||
let cleaned = card.phone.replacing(try! Regex("[^0-9+]"), with: "")
|
|
||||||
if let url = URL(string: "tel:\(cleaned)") {
|
|
||||||
openURL(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !card.website.isEmpty {
|
|
||||||
LegacyContactRowView(
|
|
||||||
fieldType: .website,
|
|
||||||
value: card.website,
|
|
||||||
label: nil
|
|
||||||
) {
|
|
||||||
let urlString = card.website.hasPrefix("http") ? card.website : "https://\(card.website)"
|
|
||||||
if let url = URL(string: urlString) {
|
|
||||||
openURL(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !card.location.isEmpty {
|
|
||||||
LegacyContactRowView(
|
|
||||||
fieldType: .address,
|
|
||||||
value: card.location,
|
|
||||||
label: nil
|
|
||||||
) {
|
|
||||||
let query = card.location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
|
||||||
if let url = URL(string: "maps://?q=\(query)") {
|
|
||||||
openURL(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy social links
|
|
||||||
LegacySocialLinksView(card: card)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LegacyContactRowView: View {
|
|
||||||
let fieldType: ContactFieldType
|
|
||||||
let value: String
|
|
||||||
let label: String?
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
|
||||||
fieldType.iconImage()
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
|
|
||||||
.background(fieldType.iconColor)
|
|
||||||
.clipShape(.circle)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
Text(value)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
|
|
||||||
if let label, !label.isEmpty {
|
|
||||||
Text(label)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
} else {
|
|
||||||
Text(fieldType.displayName)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.tertiary)
|
|
||||||
}
|
|
||||||
.contentShape(.rect)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LegacySocialLinksView: View {
|
|
||||||
@Environment(\.openURL) private var openURL
|
|
||||||
let card: BusinessCard
|
|
||||||
|
|
||||||
private var socialItems: [(fieldType: ContactFieldType, value: String)] {
|
|
||||||
var items: [(ContactFieldType, String)] = []
|
|
||||||
if !card.linkedIn.isEmpty { items.append((.linkedIn, card.linkedIn)) }
|
|
||||||
if !card.twitter.isEmpty { items.append((.twitter, card.twitter)) }
|
|
||||||
if !card.instagram.isEmpty { items.append((.instagram, card.instagram)) }
|
|
||||||
if !card.facebook.isEmpty { items.append((.facebook, card.facebook)) }
|
|
||||||
if !card.tiktok.isEmpty { items.append((.tiktok, card.tiktok)) }
|
|
||||||
if !card.github.isEmpty { items.append((.github, card.github)) }
|
|
||||||
if !card.threads.isEmpty { items.append((.threads, card.threads)) }
|
|
||||||
if !card.telegram.isEmpty { items.append((.telegram, card.telegram)) }
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ForEach(socialItems, id: \.fieldType.id) { item in
|
|
||||||
LegacyContactRowView(
|
|
||||||
fieldType: item.fieldType,
|
|
||||||
value: item.value,
|
|
||||||
label: nil
|
|
||||||
) {
|
|
||||||
if let url = item.fieldType.urlBuilder(item.value) {
|
|
||||||
openURL(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@ -373,20 +241,20 @@ private struct LegacySocialLinksView: View {
|
|||||||
displayName: "Matt Bruce",
|
displayName: "Matt Bruce",
|
||||||
role: "Lead iOS Developer",
|
role: "Lead iOS Developer",
|
||||||
company: "Toyota",
|
company: "Toyota",
|
||||||
email: "matt.bruce@toyota.com",
|
|
||||||
emailLabel: "Work",
|
|
||||||
phone: "+1 (214) 755-1043",
|
|
||||||
phoneLabel: "Cell",
|
|
||||||
website: "toyota.com",
|
|
||||||
location: "Dallas, TX",
|
|
||||||
themeName: "Coral",
|
themeName: "Coral",
|
||||||
layoutStyleRawValue: "stacked",
|
layoutStyleRawValue: "stacked",
|
||||||
headline: "Building the future of mobility",
|
headline: "Building the future of mobility"
|
||||||
linkedIn: "linkedin.com/in/mattbruce",
|
|
||||||
twitter: "twitter.com/mattbruce"
|
|
||||||
)
|
)
|
||||||
context.insert(card)
|
context.insert(card)
|
||||||
|
|
||||||
|
// Add contact fields
|
||||||
|
card.addContactField(.email, value: "matt.bruce@toyota.com", title: "Work")
|
||||||
|
card.addContactField(.phone, value: "+1 (214) 755-1043", title: "Cell")
|
||||||
|
card.addContactField(.website, value: "toyota.com", title: "")
|
||||||
|
card.addContactField(.address, value: "Dallas, TX", title: "Work")
|
||||||
|
card.addContactField(.linkedIn, value: "linkedin.com/in/mattbruce", title: "")
|
||||||
|
card.addContactField(.twitter, value: "twitter.com/mattbruce", title: "")
|
||||||
|
|
||||||
return BusinessCardView(card: card)
|
return BusinessCardView(card: card)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.AppBackground.base)
|
.background(Color.AppBackground.base)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -66,33 +66,8 @@ struct ContactDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contact info card
|
// Contact info card - shows both legacy fields and new contact fields
|
||||||
if !contact.email.isEmpty || !contact.phone.isEmpty {
|
ContactInfoCard(contact: contact, openURL: openURL)
|
||||||
VStack(spacing: 0) {
|
|
||||||
if !contact.email.isEmpty {
|
|
||||||
ContactInfoRow(
|
|
||||||
icon: "envelope.fill",
|
|
||||||
value: contact.email,
|
|
||||||
label: "Home",
|
|
||||||
action: { openURL("mailto:\(contact.email)") }
|
|
||||||
)
|
|
||||||
if !contact.phone.isEmpty {
|
|
||||||
Divider()
|
|
||||||
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !contact.phone.isEmpty {
|
|
||||||
ContactInfoRow(
|
|
||||||
icon: "phone.fill",
|
|
||||||
value: contact.phone,
|
|
||||||
label: "Cell",
|
|
||||||
action: { openURL("tel:\(contact.phone)") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color.AppBackground.card)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notes section
|
// Notes section
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
@ -295,6 +270,74 @@ private struct TagPill: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Contact Info Card
|
||||||
|
|
||||||
|
private struct ContactInfoCard: View {
|
||||||
|
let contact: Contact
|
||||||
|
let openURL: (String) -> Void
|
||||||
|
|
||||||
|
private var allContactItems: [(icon: String, value: String, label: String, urlScheme: String)] {
|
||||||
|
var items: [(icon: String, value: String, label: String, urlScheme: String)] = []
|
||||||
|
|
||||||
|
// Add from new contact fields (phones)
|
||||||
|
for field in contact.phoneNumbers {
|
||||||
|
items.append((icon: "phone.fill", value: field.value, label: field.title.isEmpty ? "Phone" : field.title, urlScheme: "tel:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add from new contact fields (emails)
|
||||||
|
for field in contact.emailAddresses {
|
||||||
|
items.append((icon: "envelope.fill", value: field.value, label: field.title.isEmpty ? "Email" : field.title, urlScheme: "mailto:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add from new contact fields (links)
|
||||||
|
for field in contact.links {
|
||||||
|
let url = field.value.hasPrefix("http") ? field.value : "https://\(field.value)"
|
||||||
|
items.append((icon: "link", value: field.value, label: field.title.isEmpty ? "Link" : field.title, urlScheme: url.hasPrefix("http") ? "" : "https://"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback: if no new fields, show legacy email/phone
|
||||||
|
if items.isEmpty {
|
||||||
|
if !contact.phone.isEmpty {
|
||||||
|
items.append((icon: "phone.fill", value: contact.phone, label: "Cell", urlScheme: "tel:"))
|
||||||
|
}
|
||||||
|
if !contact.email.isEmpty {
|
||||||
|
items.append((icon: "envelope.fill", value: contact.email, label: "Email", urlScheme: "mailto:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !allContactItems.isEmpty {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(allContactItems.indices, id: \.self) { index in
|
||||||
|
let item = allContactItems[index]
|
||||||
|
ContactInfoRow(
|
||||||
|
icon: item.icon,
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
action: {
|
||||||
|
if item.urlScheme.isEmpty {
|
||||||
|
openURL(item.value)
|
||||||
|
} else {
|
||||||
|
openURL("\(item.urlScheme)\(item.value)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if index < allContactItems.count - 1 {
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.AppBackground.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Contact Info Row
|
// MARK: - Contact Info Row
|
||||||
|
|
||||||
private struct ContactInfoRow: View {
|
private struct ContactInfoRow: View {
|
||||||
|
|||||||
@ -6,14 +6,22 @@ struct AddContactSheet: View {
|
|||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// Name fields
|
||||||
@State private var firstName = ""
|
@State private var firstName = ""
|
||||||
@State private var lastName = ""
|
@State private var lastName = ""
|
||||||
@State private var phone = ""
|
|
||||||
@State private var email = ""
|
// Professional fields
|
||||||
@State private var link = ""
|
|
||||||
@State private var jobTitle = ""
|
@State private var jobTitle = ""
|
||||||
@State private var company = ""
|
@State private var company = ""
|
||||||
|
|
||||||
|
// Contact fields with labels (multiple allowed)
|
||||||
|
@State private var phoneEntries: [LabeledEntry] = [LabeledEntry(label: "Cell", value: "")]
|
||||||
|
@State private var emailEntries: [LabeledEntry] = [LabeledEntry(label: "Work", value: "")]
|
||||||
|
@State private var linkEntries: [LabeledEntry] = [LabeledEntry(label: "Website", value: "")]
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
@State private var notes = ""
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
!firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
!firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
||||||
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
@ -28,47 +36,102 @@ struct AddContactSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
Form {
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Name section
|
// Name section
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
Section {
|
||||||
EditorTextField(placeholder: String.localized("First name"), text: $firstName)
|
TextField(String.localized("First name"), text: $firstName)
|
||||||
.textContentType(.givenName)
|
.textContentType(.givenName)
|
||||||
EditorTextField(placeholder: String.localized("Last name"), text: $lastName)
|
TextField(String.localized("Last name"), text: $lastName)
|
||||||
.textContentType(.familyName)
|
.textContentType(.familyName)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
|
||||||
.background(Color.AppBackground.elevated)
|
|
||||||
|
|
||||||
// Contact section
|
// Professional section (moved before contact)
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
Section {
|
||||||
EditorTextField(placeholder: String.localized("Phone"), text: $phone)
|
TextField(String.localized("Job title"), text: $jobTitle)
|
||||||
.keyboardType(.phonePad)
|
|
||||||
.textContentType(.telephoneNumber)
|
|
||||||
EditorTextField(placeholder: String.localized("Email"), text: $email)
|
|
||||||
.keyboardType(.emailAddress)
|
|
||||||
.textContentType(.emailAddress)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
EditorTextField(placeholder: String.localized("Link"), text: $link)
|
|
||||||
.keyboardType(.URL)
|
|
||||||
.textContentType(.URL)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
}
|
|
||||||
.padding(Design.Spacing.large)
|
|
||||||
.background(Color.AppBackground.elevated)
|
|
||||||
|
|
||||||
// Professional section
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
||||||
EditorTextField(placeholder: String.localized("Job title"), text: $jobTitle)
|
|
||||||
.textContentType(.jobTitle)
|
.textContentType(.jobTitle)
|
||||||
EditorTextField(placeholder: String.localized("Company"), text: $company)
|
TextField(String.localized("Company"), text: $company)
|
||||||
.textContentType(.organizationName)
|
.textContentType(.organizationName)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
|
||||||
.background(Color.AppBackground.elevated)
|
// Phone numbers section
|
||||||
|
Section {
|
||||||
|
ForEach($phoneEntries) { $entry in
|
||||||
|
LabeledFieldRow(
|
||||||
|
entry: $entry,
|
||||||
|
valuePlaceholder: "+1 (555) 123-4567",
|
||||||
|
labelSuggestions: ["Cell", "Work", "Home", "Main"],
|
||||||
|
keyboardType: .phonePad
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
phoneEntries.remove(atOffsets: indexSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
phoneEntries.append(LabeledEntry(label: "Cell", value: ""))
|
||||||
|
} label: {
|
||||||
|
Label(String.localized("Add phone"), systemImage: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Phone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email section
|
||||||
|
Section {
|
||||||
|
ForEach($emailEntries) { $entry in
|
||||||
|
LabeledFieldRow(
|
||||||
|
entry: $entry,
|
||||||
|
valuePlaceholder: "email@example.com",
|
||||||
|
labelSuggestions: ["Work", "Personal", "Other"],
|
||||||
|
keyboardType: .emailAddress,
|
||||||
|
autocapitalization: .never
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
emailEntries.remove(atOffsets: indexSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
emailEntries.append(LabeledEntry(label: "Work", value: ""))
|
||||||
|
} label: {
|
||||||
|
Label(String.localized("Add email"), systemImage: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Email")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links section
|
||||||
|
Section {
|
||||||
|
ForEach($linkEntries) { $entry in
|
||||||
|
LabeledFieldRow(
|
||||||
|
entry: $entry,
|
||||||
|
valuePlaceholder: "https://example.com",
|
||||||
|
labelSuggestions: ["Website", "Portfolio", "LinkedIn", "Other"],
|
||||||
|
keyboardType: .URL,
|
||||||
|
autocapitalization: .never
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
linkEntries.remove(atOffsets: indexSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
linkEntries.append(LabeledEntry(label: "Website", value: ""))
|
||||||
|
} label: {
|
||||||
|
Label(String.localized("Add link"), systemImage: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Links")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes section
|
||||||
|
Section {
|
||||||
|
TextField(String.localized("Notes about this contact..."), text: $notes, axis: .vertical)
|
||||||
|
.lineLimit(3...8)
|
||||||
|
} header: {
|
||||||
|
Text("Notes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.AppBackground.base)
|
|
||||||
.navigationTitle(String.localized("New contact"))
|
.navigationTitle(String.localized("New contact"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@ -88,27 +151,83 @@ struct AddContactSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveContact() {
|
private func saveContact() {
|
||||||
|
// Build contact fields from entries
|
||||||
|
var contactFields: [ContactField] = []
|
||||||
|
var orderIndex = 0
|
||||||
|
|
||||||
|
// Add phone entries
|
||||||
|
for entry in phoneEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
let field = ContactField(typeId: "phone", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
|
||||||
|
contactFields.append(field)
|
||||||
|
orderIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add email entries
|
||||||
|
for entry in emailEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
let field = ContactField(typeId: "email", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
|
||||||
|
contactFields.append(field)
|
||||||
|
orderIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add link entries
|
||||||
|
for entry in linkEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
let field = ContactField(typeId: "customLink", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
|
||||||
|
contactFields.append(field)
|
||||||
|
orderIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
appState.contactsStore.createContact(
|
appState.contactsStore.createContact(
|
||||||
name: fullName,
|
name: fullName,
|
||||||
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
|
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
contactFields: contactFields
|
||||||
)
|
)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shared Editor TextField
|
// MARK: - Labeled Entry Model
|
||||||
|
|
||||||
private struct EditorTextField: View {
|
private struct LabeledEntry: Identifiable {
|
||||||
let placeholder: String
|
let id = UUID()
|
||||||
@Binding var text: String
|
var label: String
|
||||||
|
var value: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Labeled Field Row
|
||||||
|
|
||||||
|
private struct LabeledFieldRow: View {
|
||||||
|
@Binding var entry: LabeledEntry
|
||||||
|
let valuePlaceholder: String
|
||||||
|
let labelSuggestions: [String]
|
||||||
|
var keyboardType: UIKeyboardType = .default
|
||||||
|
var autocapitalization: TextInputAutocapitalization = .sentences
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
TextField(placeholder, text: $text)
|
// Label picker
|
||||||
Divider()
|
Menu {
|
||||||
|
ForEach(labelSuggestions, id: \.self) { suggestion in
|
||||||
|
Button(suggestion) {
|
||||||
|
entry.label = suggestion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(entry.label)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
|
||||||
|
// Value field
|
||||||
|
TextField(valuePlaceholder, text: $entry.value)
|
||||||
|
.keyboardType(keyboardType)
|
||||||
|
.textInputAutocapitalization(autocapitalization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -263,14 +263,6 @@ struct BusinessCardTests {
|
|||||||
|
|
||||||
// Tests for high priority features
|
// Tests for high priority features
|
||||||
|
|
||||||
@Test func cardSocialLinksDetection() async throws {
|
|
||||||
let card = BusinessCard(displayName: "Test")
|
|
||||||
#expect(!card.hasSocialLinks)
|
|
||||||
|
|
||||||
card.linkedIn = "linkedin.com/in/test"
|
|
||||||
#expect(card.hasSocialLinks)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func contactNotesAndTags() async throws {
|
@Test func contactNotesAndTags() async throws {
|
||||||
let contact = Contact(
|
let contact = Contact(
|
||||||
name: "Test Contact",
|
name: "Test Contact",
|
||||||
|
|||||||
@ -16,9 +16,10 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
|
|||||||
- Tap the **edit icon** (pencil) in the top right to edit the current card
|
- Tap the **edit icon** (pencil) in the top right to edit the current card
|
||||||
- Tap the **plus icon** to create a new card
|
- Tap the **plus icon** to create a new card
|
||||||
- Set a default card for sharing
|
- Set a default card for sharing
|
||||||
- **Modern card design**: Banner with company logo, overlapping profile photo, clean contact rows
|
- **Modern card design**: Banner with optional cover photo, company logo, overlapping profile photo, clean contact rows
|
||||||
- **Profile photos**: Add a photo from your library or use an icon
|
- **Profile photos**: Add a headshot from your library or use an icon
|
||||||
- **Company logos**: Upload a logo to display on your card's banner
|
- **Cover photos**: Add a custom banner background image
|
||||||
|
- **Company logos**: Upload a logo to overlay on your card's banner
|
||||||
- **Rich profiles**: First/middle/last name, prefix, maiden name, preferred name, pronouns, headline, bio, accreditations
|
- **Rich profiles**: First/middle/last name, prefix, maiden name, preferred name, pronouns, headline, bio, accreditations
|
||||||
- **Clickable contact fields**: Tap any field to call, email, open link, or launch app
|
- **Clickable contact fields**: Tap any field to call, email, open link, or launch app
|
||||||
|
|
||||||
|
|||||||
@ -53,8 +53,8 @@ App-specific extensions are in `Design/DesignConstants.swift`:
|
|||||||
- Rich fields: pronouns, bio, headline, accreditations (comma-separated tags)
|
- Rich fields: pronouns, bio, headline, accreditations (comma-separated tags)
|
||||||
- **Dynamic contact fields**: `@Relationship` to array of `ContactField` objects
|
- **Dynamic contact fields**: `@Relationship` to array of `ContactField` objects
|
||||||
- Legacy fields: social links (LinkedIn, Twitter, etc.) for backward compatibility
|
- Legacy fields: social links (LinkedIn, Twitter, etc.) for backward compatibility
|
||||||
- Photo: `photoData` and `logoData` stored with `@Attribute(.externalStorage)`
|
- Photos: `photoData` (profile), `coverPhotoData` (banner background), `logoData` (company logo) stored with `@Attribute(.externalStorage)`
|
||||||
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks`, `orderedContactFields`
|
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `orderedContactFields`
|
||||||
- Helper methods: `addContactField`, `removeContactField`, `reorderContactFields`
|
- Helper methods: `addContactField`, `removeContactField`, `reorderContactFields`
|
||||||
|
|
||||||
- `Models/ContactField.swift` — SwiftData model for dynamic contact fields:
|
- `Models/ContactField.swift` — SwiftData model for dynamic contact fields:
|
||||||
@ -112,7 +112,7 @@ Main screens:
|
|||||||
|
|
||||||
Feature views:
|
Feature views:
|
||||||
- `Views/BusinessCardView.swift` — card display with layouts
|
- `Views/BusinessCardView.swift` — card display with layouts
|
||||||
- `Views/CardEditorView.swift` — create/edit cards with PhotosPicker
|
- `Views/CardEditorView.swift` — create/edit cards with PhotosPicker for 3 image types (profile, cover, logo)
|
||||||
- `Views/ContactDetailView.swift` — full contact view with annotations
|
- `Views/ContactDetailView.swift` — full contact view with annotations
|
||||||
- `Views/QRScannerView.swift` — camera-based QR scanner
|
- `Views/QRScannerView.swift` — camera-based QR scanner
|
||||||
- `Views/QRCodeView.swift` — QR code image generator
|
- `Views/QRCodeView.swift` — QR code image generator
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user