Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
0b71f5ea65
commit
540bd095dd
@ -347,6 +347,7 @@ If SwiftData is configured to use CloudKit:
|
|||||||
- Never use `@Attribute(.unique)`.
|
- Never use `@Attribute(.unique)`.
|
||||||
- Model properties must have default values or be optional.
|
- Model properties must have default values or be optional.
|
||||||
- All relationships must be marked optional.
|
- All relationships must be marked optional.
|
||||||
|
- Before changing any shipped `@Model` schema, follow `CLOUDKIT_SCHEMA_GUARDRAILS.md`.
|
||||||
|
|
||||||
|
|
||||||
## Model Design: Single Source of Truth
|
## Model Design: Single Source of Truth
|
||||||
|
|||||||
@ -9,6 +9,9 @@ struct BusinessCardApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
let schema = Schema([BusinessCard.self, Contact.self, ContactField.self])
|
let schema = Schema([BusinessCard.self, Contact.self, ContactField.self])
|
||||||
|
let cloudKitDatabase: ModelConfiguration.CloudKitDatabase = .private(
|
||||||
|
AppIdentifiers.cloudKitContainerIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
// Register app theme for Bedrock semantic text/surface colors.
|
// Register app theme for Bedrock semantic text/surface colors.
|
||||||
Theme.register(
|
Theme.register(
|
||||||
@ -19,8 +22,7 @@ struct BusinessCardApp: App {
|
|||||||
)
|
)
|
||||||
Theme.register(border: AppBorder.self)
|
Theme.register(border: AppBorder.self)
|
||||||
|
|
||||||
// Primary strategy: App Group for watch sync (without CloudKit for now)
|
// Primary strategy: App Group-backed persistent store with CloudKit sync.
|
||||||
// CloudKit can be enabled once properly configured in Xcode
|
|
||||||
var container: ModelContainer?
|
var container: ModelContainer?
|
||||||
|
|
||||||
if let appGroupURL = FileManager.default.containerURL(
|
if let appGroupURL = FileManager.default.containerURL(
|
||||||
@ -30,7 +32,7 @@ struct BusinessCardApp: App {
|
|||||||
let config = ModelConfiguration(
|
let config = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
url: storeURL,
|
url: storeURL,
|
||||||
cloudKitDatabase: .none // Disable CloudKit until properly configured
|
cloudKitDatabase: cloudKitDatabase
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@ -46,7 +48,7 @@ struct BusinessCardApp: App {
|
|||||||
let config = ModelConfiguration(
|
let config = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
url: storeURL,
|
url: storeURL,
|
||||||
cloudKitDatabase: .none
|
cloudKitDatabase: cloudKitDatabase
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|||||||
@ -222,133 +222,16 @@ final class BusinessCard {
|
|||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display-friendly preferred name fallback.
|
||||||
|
var displayName: String {
|
||||||
|
if !preferredName.isEmpty { return preferredName }
|
||||||
|
if !firstName.isEmpty { return firstName }
|
||||||
|
return fullName
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
var vCardPayload: String {
|
var vCardPayload: String {
|
||||||
var lines = [
|
buildVCardPayload(includePhotoData: nil)
|
||||||
"BEGIN:VCARD",
|
|
||||||
"VERSION:3.0"
|
|
||||||
]
|
|
||||||
|
|
||||||
// N: Structured name - REQUIRED for proper contact import
|
|
||||||
let structuredName = [lastName, firstName, middleName, prefix, suffix]
|
|
||||||
.map { escapeVCardValue($0) }
|
|
||||||
.joined(separator: ";")
|
|
||||||
lines.append("N:\(structuredName)")
|
|
||||||
|
|
||||||
// FN: Formatted name
|
|
||||||
let formattedName = vCardName
|
|
||||||
lines.append("FN:\(escapeVCardValue(formattedName))")
|
|
||||||
|
|
||||||
// NICKNAME: Preferred name
|
|
||||||
if !preferredName.isEmpty {
|
|
||||||
lines.append("NICKNAME:\(escapeVCardValue(preferredName))")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ORG: Organization
|
|
||||||
if !company.isEmpty {
|
|
||||||
if !department.isEmpty {
|
|
||||||
lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))")
|
|
||||||
} else {
|
|
||||||
lines.append("ORG:\(escapeVCardValue(company))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TITLE: Job title/role
|
|
||||||
if !role.isEmpty {
|
|
||||||
lines.append("TITLE:\(escapeVCardValue(role))")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contact fields from the array
|
|
||||||
for field in orderedContactFields {
|
|
||||||
let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !value.isEmpty else { continue }
|
|
||||||
|
|
||||||
switch field.typeId {
|
|
||||||
case "email":
|
|
||||||
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
|
|
||||||
lines.append("EMAIL;TYPE=\(typeLabel):\(escapeVCardValue(value))")
|
|
||||||
case "phone":
|
|
||||||
let typeLabel = field.title.isEmpty ? "CELL" : field.title.uppercased()
|
|
||||||
lines.append("TEL;TYPE=\(typeLabel):\(escapeVCardValue(value))")
|
|
||||||
case "website":
|
|
||||||
lines.append("URL:\(escapeVCardValue(value))")
|
|
||||||
case "address":
|
|
||||||
// Use structured PostalAddress for proper VCard ADR format
|
|
||||||
if let postalAddress = field.postalAddress {
|
|
||||||
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
|
|
||||||
lines.append(postalAddress.vCardADRLine(type: typeLabel))
|
|
||||||
} else {
|
|
||||||
// Fallback for legacy data
|
|
||||||
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(value));;;;")
|
|
||||||
}
|
|
||||||
case "linkedIn":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(value))")
|
|
||||||
case "twitter":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(escapeVCardValue(value))")
|
|
||||||
case "instagram":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(escapeVCardValue(value))")
|
|
||||||
case "facebook":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=facebook:\(escapeVCardValue(value))")
|
|
||||||
case "tiktok":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=tiktok:\(escapeVCardValue(value))")
|
|
||||||
case "github":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=github:\(escapeVCardValue(value))")
|
|
||||||
case "threads":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=threads:\(escapeVCardValue(value))")
|
|
||||||
case "telegram":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=telegram:\(escapeVCardValue(value))")
|
|
||||||
case "bluesky":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=bluesky:\(escapeVCardValue(value))")
|
|
||||||
case "mastodon":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=mastodon:\(escapeVCardValue(value))")
|
|
||||||
case "youtube":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=youtube:\(escapeVCardValue(value))")
|
|
||||||
case "twitch":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=twitch:\(escapeVCardValue(value))")
|
|
||||||
case "reddit":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=reddit:\(escapeVCardValue(value))")
|
|
||||||
case "snapchat":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=snapchat:\(escapeVCardValue(value))")
|
|
||||||
case "pinterest":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=pinterest:\(escapeVCardValue(value))")
|
|
||||||
case "discord":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=discord:\(escapeVCardValue(value))")
|
|
||||||
case "slack":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=slack:\(escapeVCardValue(value))")
|
|
||||||
case "whatsapp":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=whatsapp:\(escapeVCardValue(value))")
|
|
||||||
case "signal":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=signal:\(escapeVCardValue(value))")
|
|
||||||
case "venmo":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=venmo:\(escapeVCardValue(value))")
|
|
||||||
case "cashApp":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=cashapp:\(escapeVCardValue(value))")
|
|
||||||
case "paypal":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=paypal:\(escapeVCardValue(value))")
|
|
||||||
case "calendly":
|
|
||||||
lines.append("URL;TYPE=calendly:\(escapeVCardValue(value))")
|
|
||||||
case "customLink":
|
|
||||||
let label = field.title.isEmpty ? "OTHER" : field.title.uppercased()
|
|
||||||
lines.append("URL;TYPE=\(label):\(escapeVCardValue(value))")
|
|
||||||
default:
|
|
||||||
if value.contains("://") || value.contains(".") {
|
|
||||||
lines.append("URL:\(escapeVCardValue(value))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Bio, headline, and accreditations
|
|
||||||
var notes: [String] = []
|
|
||||||
if !headline.isEmpty { notes.append(headline) }
|
|
||||||
if !bio.isEmpty { notes.append(bio) }
|
|
||||||
if !accreditations.isEmpty { notes.append("Credentials: \(accreditations)") }
|
|
||||||
if !pronouns.isEmpty { notes.append("Pronouns: \(pronouns)") }
|
|
||||||
if !notes.isEmpty {
|
|
||||||
lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))")
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.append("END:VCARD")
|
|
||||||
return lines.joined(separator: "\r\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a vCard payload with embedded profile photo for file-based sharing.
|
/// Generates a vCard payload with embedded profile photo for file-based sharing.
|
||||||
@ -356,33 +239,38 @@ final class BusinessCard {
|
|||||||
/// Messages, Email attachments, and other file-based sharing methods.
|
/// Messages, Email attachments, and other file-based sharing methods.
|
||||||
@MainActor
|
@MainActor
|
||||||
var vCardFilePayload: String {
|
var vCardFilePayload: String {
|
||||||
var lines = [
|
buildVCardPayload(includePhotoData: photoData)
|
||||||
"BEGIN:VCARD",
|
}
|
||||||
"VERSION:3.0"
|
|
||||||
]
|
private var vCardStructuredName: String {
|
||||||
|
[lastName, firstName, middleName, prefix, suffix]
|
||||||
// N: Structured name - REQUIRED for proper contact import
|
.map(escapeVCardValue)
|
||||||
let structuredName = [lastName, firstName, middleName, prefix, suffix]
|
.joined(separator: ";")
|
||||||
.map { escapeVCardValue($0) }
|
}
|
||||||
.joined(separator: ";")
|
|
||||||
lines.append("N:\(structuredName)")
|
private var vCardNoteEntries: [String] {
|
||||||
|
var notes: [String] = []
|
||||||
// FN: Formatted name
|
if !headline.isEmpty { notes.append(headline) }
|
||||||
let formattedName = vCardName
|
if !bio.isEmpty { notes.append(bio) }
|
||||||
lines.append("FN:\(escapeVCardValue(formattedName))")
|
if !accreditations.isEmpty { notes.append("Credentials: \(accreditations)") }
|
||||||
|
if !pronouns.isEmpty { notes.append("Pronouns: \(pronouns)") }
|
||||||
// PHOTO: Embedded profile photo as base64-encoded JPEG
|
return notes
|
||||||
if let photoData {
|
}
|
||||||
let base64Photo = photoData.base64EncodedString()
|
|
||||||
lines.append("PHOTO;ENCODING=b;TYPE=JPEG:\(base64Photo)")
|
@MainActor
|
||||||
|
private func buildVCardPayload(includePhotoData: Data?) -> String {
|
||||||
|
var lines = ["BEGIN:VCARD", "VERSION:3.0"]
|
||||||
|
lines.append("N:\(vCardStructuredName)")
|
||||||
|
lines.append("FN:\(escapeVCardValue(vCardName))")
|
||||||
|
|
||||||
|
if let includePhotoData {
|
||||||
|
lines.append("PHOTO;ENCODING=b;TYPE=JPEG:\(includePhotoData.base64EncodedString())")
|
||||||
}
|
}
|
||||||
|
|
||||||
// NICKNAME: Preferred name
|
|
||||||
if !preferredName.isEmpty {
|
if !preferredName.isEmpty {
|
||||||
lines.append("NICKNAME:\(escapeVCardValue(preferredName))")
|
lines.append("NICKNAME:\(escapeVCardValue(preferredName))")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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))")
|
||||||
@ -391,102 +279,98 @@ final class BusinessCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TITLE: Job title/role
|
|
||||||
if !role.isEmpty {
|
if !role.isEmpty {
|
||||||
lines.append("TITLE:\(escapeVCardValue(role))")
|
lines.append("TITLE:\(escapeVCardValue(role))")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 }
|
||||||
|
lines.append(contentsOf: vCardLines(for: field, value: value))
|
||||||
switch field.typeId {
|
|
||||||
case "email":
|
|
||||||
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
|
|
||||||
lines.append("EMAIL;TYPE=\(typeLabel):\(escapeVCardValue(value))")
|
|
||||||
case "phone":
|
|
||||||
let typeLabel = field.title.isEmpty ? "CELL" : field.title.uppercased()
|
|
||||||
lines.append("TEL;TYPE=\(typeLabel):\(escapeVCardValue(value))")
|
|
||||||
case "website":
|
|
||||||
lines.append("URL:\(escapeVCardValue(value))")
|
|
||||||
case "address":
|
|
||||||
if let postalAddress = field.postalAddress {
|
|
||||||
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
|
|
||||||
lines.append(postalAddress.vCardADRLine(type: typeLabel))
|
|
||||||
} else {
|
|
||||||
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(value));;;;")
|
|
||||||
}
|
|
||||||
case "linkedIn":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(value))")
|
|
||||||
case "twitter":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(escapeVCardValue(value))")
|
|
||||||
case "instagram":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(escapeVCardValue(value))")
|
|
||||||
case "facebook":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=facebook:\(escapeVCardValue(value))")
|
|
||||||
case "tiktok":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=tiktok:\(escapeVCardValue(value))")
|
|
||||||
case "github":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=github:\(escapeVCardValue(value))")
|
|
||||||
case "threads":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=threads:\(escapeVCardValue(value))")
|
|
||||||
case "telegram":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=telegram:\(escapeVCardValue(value))")
|
|
||||||
case "bluesky":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=bluesky:\(escapeVCardValue(value))")
|
|
||||||
case "mastodon":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=mastodon:\(escapeVCardValue(value))")
|
|
||||||
case "youtube":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=youtube:\(escapeVCardValue(value))")
|
|
||||||
case "twitch":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=twitch:\(escapeVCardValue(value))")
|
|
||||||
case "reddit":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=reddit:\(escapeVCardValue(value))")
|
|
||||||
case "snapchat":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=snapchat:\(escapeVCardValue(value))")
|
|
||||||
case "pinterest":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=pinterest:\(escapeVCardValue(value))")
|
|
||||||
case "discord":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=discord:\(escapeVCardValue(value))")
|
|
||||||
case "slack":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=slack:\(escapeVCardValue(value))")
|
|
||||||
case "whatsapp":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=whatsapp:\(escapeVCardValue(value))")
|
|
||||||
case "signal":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=signal:\(escapeVCardValue(value))")
|
|
||||||
case "venmo":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=venmo:\(escapeVCardValue(value))")
|
|
||||||
case "cashApp":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=cashapp:\(escapeVCardValue(value))")
|
|
||||||
case "paypal":
|
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=paypal:\(escapeVCardValue(value))")
|
|
||||||
case "calendly":
|
|
||||||
lines.append("URL;TYPE=calendly:\(escapeVCardValue(value))")
|
|
||||||
case "customLink":
|
|
||||||
let label = field.title.isEmpty ? "OTHER" : field.title.uppercased()
|
|
||||||
lines.append("URL;TYPE=\(label):\(escapeVCardValue(value))")
|
|
||||||
default:
|
|
||||||
if value.contains("://") || value.contains(".") {
|
|
||||||
lines.append("URL:\(escapeVCardValue(value))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Bio, headline, and accreditations
|
if !vCardNoteEntries.isEmpty {
|
||||||
var notes: [String] = []
|
lines.append("NOTE:\(escapeVCardValue(vCardNoteEntries.joined(separator: "\\n")))")
|
||||||
if !headline.isEmpty { notes.append(headline) }
|
|
||||||
if !bio.isEmpty { notes.append(bio) }
|
|
||||||
if !accreditations.isEmpty { notes.append("Credentials: \(accreditations)") }
|
|
||||||
if !pronouns.isEmpty { notes.append("Pronouns: \(pronouns)") }
|
|
||||||
if !notes.isEmpty {
|
|
||||||
lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.append("END:VCARD")
|
lines.append("END:VCARD")
|
||||||
return lines.joined(separator: "\r\n")
|
return lines.joined(separator: "\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func vCardLines(for field: ContactField, value: String) -> [String] {
|
||||||
|
switch field.typeId {
|
||||||
|
case "email":
|
||||||
|
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
|
||||||
|
return ["EMAIL;TYPE=\(typeLabel):\(escapeVCardValue(value))"]
|
||||||
|
case "phone":
|
||||||
|
let typeLabel = field.title.isEmpty ? "CELL" : field.title.uppercased()
|
||||||
|
return ["TEL;TYPE=\(typeLabel):\(escapeVCardValue(value))"]
|
||||||
|
case "website":
|
||||||
|
return ["URL:\(escapeVCardValue(value))"]
|
||||||
|
case "address":
|
||||||
|
if let postalAddress = field.postalAddress {
|
||||||
|
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
|
||||||
|
return [postalAddress.vCardADRLine(type: typeLabel)]
|
||||||
|
}
|
||||||
|
return ["ADR;TYPE=WORK:;;\(escapeVCardValue(value));;;;"]
|
||||||
|
case "linkedIn":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(value))"]
|
||||||
|
case "twitter":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=twitter:\(escapeVCardValue(value))"]
|
||||||
|
case "instagram":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=instagram:\(escapeVCardValue(value))"]
|
||||||
|
case "facebook":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=facebook:\(escapeVCardValue(value))"]
|
||||||
|
case "tiktok":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=tiktok:\(escapeVCardValue(value))"]
|
||||||
|
case "github":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=github:\(escapeVCardValue(value))"]
|
||||||
|
case "threads":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=threads:\(escapeVCardValue(value))"]
|
||||||
|
case "telegram":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=telegram:\(escapeVCardValue(value))"]
|
||||||
|
case "bluesky":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=bluesky:\(escapeVCardValue(value))"]
|
||||||
|
case "mastodon":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=mastodon:\(escapeVCardValue(value))"]
|
||||||
|
case "youtube":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=youtube:\(escapeVCardValue(value))"]
|
||||||
|
case "twitch":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=twitch:\(escapeVCardValue(value))"]
|
||||||
|
case "reddit":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=reddit:\(escapeVCardValue(value))"]
|
||||||
|
case "snapchat":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=snapchat:\(escapeVCardValue(value))"]
|
||||||
|
case "pinterest":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=pinterest:\(escapeVCardValue(value))"]
|
||||||
|
case "discord":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=discord:\(escapeVCardValue(value))"]
|
||||||
|
case "slack":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=slack:\(escapeVCardValue(value))"]
|
||||||
|
case "whatsapp":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=whatsapp:\(escapeVCardValue(value))"]
|
||||||
|
case "signal":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=signal:\(escapeVCardValue(value))"]
|
||||||
|
case "venmo":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=venmo:\(escapeVCardValue(value))"]
|
||||||
|
case "cashApp":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=cashapp:\(escapeVCardValue(value))"]
|
||||||
|
case "paypal":
|
||||||
|
return ["X-SOCIALPROFILE;TYPE=paypal:\(escapeVCardValue(value))"]
|
||||||
|
case "calendly":
|
||||||
|
return ["URL;TYPE=calendly:\(escapeVCardValue(value))"]
|
||||||
|
case "customLink":
|
||||||
|
let label = field.title.isEmpty ? "OTHER" : field.title.uppercased()
|
||||||
|
return ["URL;TYPE=\(label):\(escapeVCardValue(value))"]
|
||||||
|
default:
|
||||||
|
if value.contains("://") || value.contains(".") {
|
||||||
|
return ["URL:\(escapeVCardValue(value))"]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Escapes special characters for vCard format
|
/// Escapes special characters for vCard format
|
||||||
private func escapeVCardValue(_ value: String) -> String {
|
private func escapeVCardValue(_ value: String) -> String {
|
||||||
value
|
value
|
||||||
@ -497,6 +381,8 @@ final class BusinessCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension BusinessCard: VCardPayloadProviding {}
|
||||||
|
|
||||||
extension BusinessCard {
|
extension BusinessCard {
|
||||||
@MainActor
|
@MainActor
|
||||||
static func createSamples(in context: ModelContext) {
|
static func createSamples(in context: ModelContext) {
|
||||||
|
|||||||
7
BusinessCard/Protocols/AppMetadataProviding.swift
Normal file
7
BusinessCard/Protocols/AppMetadataProviding.swift
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol AppMetadataProviding {
|
||||||
|
var appName: String { get }
|
||||||
|
var appVersion: String { get }
|
||||||
|
var buildNumber: String { get }
|
||||||
|
}
|
||||||
11
BusinessCard/Protocols/VCardPayloadProviding.swift
Normal file
11
BusinessCard/Protocols/VCardPayloadProviding.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol VCardPayloadProviding {
|
||||||
|
var vCardName: String { get }
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
var vCardPayload: String { get }
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
var vCardFilePayload: String { get }
|
||||||
|
}
|
||||||
23
BusinessCard/Services/BundleAppMetadataProvider.swift
Normal file
23
BusinessCard/Services/BundleAppMetadataProvider.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BundleAppMetadataProvider: AppMetadataProviding {
|
||||||
|
private let bundle: Bundle
|
||||||
|
|
||||||
|
init(bundle: Bundle = .main) {
|
||||||
|
self.bundle = bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
var appName: String {
|
||||||
|
bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||||
|
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||||
|
?? "BusinessCard"
|
||||||
|
}
|
||||||
|
|
||||||
|
var appVersion: String {
|
||||||
|
bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
var buildNumber: String {
|
||||||
|
bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,15 +7,15 @@ struct VCardFileService {
|
|||||||
/// Creates a temporary .vcf file for the given card and returns its URL.
|
/// Creates a temporary .vcf file for the given card and returns its URL.
|
||||||
/// The file includes the embedded profile photo if available.
|
/// The file includes the embedded profile photo if available.
|
||||||
@MainActor
|
@MainActor
|
||||||
func createVCardFile(for card: BusinessCard) throws -> URL {
|
func createVCardFile(for payloadProvider: some VCardPayloadProviding) throws -> URL {
|
||||||
let payload = card.vCardFilePayload
|
let payload = payloadProvider.vCardFilePayload
|
||||||
|
|
||||||
// Create a sanitized filename from the card's name
|
// Create a sanitized filename from the card's name
|
||||||
let sanitizedName = sanitizeFilename(card.vCardName)
|
let sanitizedName = sanitizeFilename(payloadProvider.vCardName)
|
||||||
let filename = sanitizedName.isEmpty ? "contact" : sanitizedName
|
let filename = sanitizedName.isEmpty ? "contact" : sanitizedName
|
||||||
|
|
||||||
// Write to temp directory with .vcf extension
|
// Write to temp directory with .vcf extension
|
||||||
let tempURL = FileManager.default.temporaryDirectory
|
let tempURL = URL.temporaryDirectory
|
||||||
.appending(path: "\(filename).vcf")
|
.appending(path: "\(filename).vcf")
|
||||||
|
|
||||||
// Remove existing file if present
|
// Remove existing file if present
|
||||||
|
|||||||
42
BusinessCard/State/AppPreferencesStore.swift
Normal file
42
BusinessCard/State/AppPreferencesStore.swift
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class AppPreferencesStore {
|
||||||
|
private enum Keys {
|
||||||
|
static let appearance = "appAppearance"
|
||||||
|
static let debugPremiumEnabled = "debugPremiumEnabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let userDefaults: UserDefaults
|
||||||
|
|
||||||
|
var appearance: AppAppearance {
|
||||||
|
didSet {
|
||||||
|
userDefaults.set(appearance.rawValue, forKey: Keys.appearance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
var isDebugPremiumEnabled: Bool {
|
||||||
|
get { userDefaults.bool(forKey: Keys.debugPremiumEnabled) }
|
||||||
|
set { userDefaults.set(newValue, forKey: Keys.debugPremiumEnabled) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var preferredColorScheme: ColorScheme? {
|
||||||
|
appearance.preferredColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
init(userDefaults: UserDefaults = .standard) {
|
||||||
|
self.userDefaults = userDefaults
|
||||||
|
|
||||||
|
if let rawValue = userDefaults.string(forKey: Keys.appearance),
|
||||||
|
let savedAppearance = AppAppearance(rawValue: rawValue) {
|
||||||
|
self.appearance = savedAppearance
|
||||||
|
} else {
|
||||||
|
self.appearance = .system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,34 +20,26 @@ enum AppAppearance: String, CaseIterable, Sendable {
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AppState {
|
final class AppState {
|
||||||
private enum DefaultsKey {
|
|
||||||
static let appearance = "appAppearance"
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedTab: AppTab = .cards
|
var selectedTab: AppTab = .cards
|
||||||
var cardStore: CardStore
|
var cardStore: CardStore
|
||||||
var contactsStore: ContactsStore
|
var contactsStore: ContactsStore
|
||||||
|
let preferences: AppPreferencesStore
|
||||||
let shareLinkService: ShareLinkProviding
|
let shareLinkService: ShareLinkProviding
|
||||||
var appearance: AppAppearance {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(appearance.rawValue, forKey: DefaultsKey.appearance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var preferredColorScheme: ColorScheme? {
|
var preferredColorScheme: ColorScheme? {
|
||||||
appearance.preferredColorScheme
|
preferences.preferredColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
var appearance: AppAppearance {
|
||||||
|
get { preferences.appearance }
|
||||||
|
set { preferences.appearance = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
init(modelContext: ModelContext) {
|
init(modelContext: ModelContext) {
|
||||||
self.cardStore = CardStore(modelContext: modelContext)
|
self.cardStore = CardStore(modelContext: modelContext)
|
||||||
self.contactsStore = ContactsStore(modelContext: modelContext)
|
self.contactsStore = ContactsStore(modelContext: modelContext)
|
||||||
|
self.preferences = AppPreferencesStore()
|
||||||
self.shareLinkService = ShareLinkService()
|
self.shareLinkService = ShareLinkService()
|
||||||
if let rawValue = UserDefaults.standard.string(forKey: DefaultsKey.appearance),
|
|
||||||
let savedAppearance = AppAppearance(rawValue: rawValue) {
|
|
||||||
self.appearance = savedAppearance
|
|
||||||
} else {
|
|
||||||
self.appearance = .system
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@ -140,9 +140,9 @@ final class ContactsStore: ContactTracking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func relativeShareDate(for contact: Contact) -> String {
|
func relativeShareDate(for contact: Contact) -> String {
|
||||||
let formatter = RelativeDateTimeFormatter()
|
contact.lastSharedDate.formatted(
|
||||||
formatter.unitsStyle = .short
|
.relative(presentation: .named, unitsStyle: .abbreviated)
|
||||||
return formatter.localizedString(for: contact.lastSharedDate, relativeTo: .now)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveContext() {
|
private func saveContext() {
|
||||||
|
|||||||
@ -11,24 +11,23 @@ import Observation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SettingsState {
|
final class SettingsState {
|
||||||
|
private let metadataProvider: AppMetadataProviding
|
||||||
|
|
||||||
// MARK: - App Info
|
// MARK: - App Info
|
||||||
|
|
||||||
/// The app's display name.
|
/// The app's display name.
|
||||||
var appName: String {
|
var appName: String {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
metadataProvider.appName
|
||||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
|
||||||
?? "BusinessCard"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The app's version string (e.g., "1.0.0").
|
/// The app's version string (e.g., "1.0.0").
|
||||||
var appVersion: String {
|
var appVersion: String {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
metadataProvider.appVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The app's build number.
|
/// The app's build number.
|
||||||
var buildNumber: String {
|
var buildNumber: String {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
metadataProvider.buildNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Combined version and build string for display.
|
/// Combined version and build string for display.
|
||||||
@ -36,17 +35,9 @@ final class SettingsState {
|
|||||||
"Version \(appVersion) (\(buildNumber))"
|
"Version \(appVersion) (\(buildNumber))"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Debug Settings
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
/// Debug-only: Simulates premium being unlocked for testing.
|
|
||||||
var isDebugPremiumEnabled: Bool {
|
|
||||||
get { UserDefaults.standard.bool(forKey: "debugPremiumEnabled") }
|
|
||||||
set { UserDefaults.standard.set(newValue, forKey: "debugPremiumEnabled") }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {}
|
init(metadataProvider: AppMetadataProviding = BundleAppMetadataProvider()) {
|
||||||
|
self.metadataProvider = metadataProvider
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,8 +63,8 @@ struct SettingsView: View {
|
|||||||
("Dark", AppAppearance.dark)
|
("Dark", AppAppearance.dark)
|
||||||
],
|
],
|
||||||
selection: Binding(
|
selection: Binding(
|
||||||
get: { appState.appearance },
|
get: { appState.preferences.appearance },
|
||||||
set: { appState.appearance = $0 }
|
set: { appState.preferences.appearance = $0 }
|
||||||
),
|
),
|
||||||
accentColor: AppThemeAccent.primary
|
accentColor: AppThemeAccent.primary
|
||||||
)
|
)
|
||||||
@ -161,8 +161,8 @@ struct SettingsView: View {
|
|||||||
title: "Enable Debug Premium",
|
title: "Enable Debug Premium",
|
||||||
subtitle: "Unlock all premium features for testing",
|
subtitle: "Unlock all premium features for testing",
|
||||||
isOn: Binding(
|
isOn: Binding(
|
||||||
get: { settingsState.isDebugPremiumEnabled },
|
get: { appState.preferences.isDebugPremiumEnabled },
|
||||||
set: { settingsState.isDebugPremiumEnabled = $0 }
|
set: { appState.preferences.isDebugPremiumEnabled = $0 }
|
||||||
),
|
),
|
||||||
accentColor: AppStatus.warning
|
accentColor: AppStatus.warning
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct WidgetPhonePreviewCard: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
WidgetSurfaceCard {
|
||||||
|
Label("Phone Widget", systemImage: "iphone")
|
||||||
|
.styled(.headingEmphasis)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
QRCodeView(payload: card.vCardPayload)
|
||||||
|
.frame(width: Design.CardSize.widgetPhoneHeight, height: Design.CardSize.widgetPhoneHeight)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(card.fullName)
|
||||||
|
.styled(.subheadingEmphasis)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text(card.role)
|
||||||
|
.styled(.caption, emphasis: .secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text("Tap to share")
|
||||||
|
.styled(.caption2, emphasis: .tertiary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct WidgetPreviewCardView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
WidgetPhonePreviewCard(card: card)
|
||||||
|
WidgetWatchPreviewCard(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct WidgetSurfaceCard<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.AppBackground.secondary.opacity(Design.Opacity.almostFull))
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
||||||
|
}
|
||||||
|
.shadow(
|
||||||
|
color: Color.AppText.tertiary.opacity(Design.Opacity.subtle),
|
||||||
|
radius: Design.Shadow.radiusSmall,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct WidgetWatchPreviewCard: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
WidgetSurfaceCard {
|
||||||
|
Label("Watch Widget", systemImage: "applewatch")
|
||||||
|
.styled(.headingEmphasis)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
QRCodeView(payload: card.vCardPayload)
|
||||||
|
.frame(width: Design.CardSize.widgetWatchSize, height: Design.CardSize.widgetWatchSize)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text("Ready to scan")
|
||||||
|
.styled(.subheadingEmphasis, emphasis: .secondary)
|
||||||
|
|
||||||
|
Text("Open on Apple Watch")
|
||||||
|
.styled(.caption, emphasis: .tertiary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct WidgetsEmptyStateCardView: View {
|
||||||
|
var body: some View {
|
||||||
|
WidgetSurfaceCard {
|
||||||
|
Label("No card selected", systemImage: "person.crop.rectangle")
|
||||||
|
.styled(.headingEmphasis)
|
||||||
|
|
||||||
|
Text("Select a business card from the Cards tab to preview widget layouts.")
|
||||||
|
.styled(.subheading, emphasis: .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct WidgetsHeroCardView: View {
|
||||||
|
var body: some View {
|
||||||
|
WidgetSurfaceCard {
|
||||||
|
Label("Widgets on iPhone and Watch", systemImage: "square.grid.2x2.fill")
|
||||||
|
.styled(.headingEmphasis)
|
||||||
|
|
||||||
|
Text("Share your card quickly from your home screen and your watch face.")
|
||||||
|
.styled(.subheading, emphasis: .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
BusinessCard/Views/Widgets/WidgetsView.swift
Normal file
38
BusinessCard/Views/Widgets/WidgetsView.swift
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct WidgetsView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
WidgetsHeroCardView()
|
||||||
|
|
||||||
|
if let card = appState.cardStore.selectedCard {
|
||||||
|
WidgetPreviewCardView(card: card)
|
||||||
|
} else {
|
||||||
|
WidgetsEmptyStateCardView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
.navigationTitle(String.localized("Widgets"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
WidgetsView()
|
||||||
|
}
|
||||||
@ -1,157 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Bedrock
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct WidgetsView: View {
|
|
||||||
@Environment(AppState.self) private var appState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: Design.Spacing.large) {
|
|
||||||
WidgetsHeroCard()
|
|
||||||
|
|
||||||
if let card = appState.cardStore.selectedCard {
|
|
||||||
WidgetPreviewCardView(card: card)
|
|
||||||
} else {
|
|
||||||
WidgetsEmptyStateCard()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
|
||||||
.padding(.vertical, Design.Spacing.xLarge)
|
|
||||||
}
|
|
||||||
.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
.navigationTitle(String.localized("Widgets"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WidgetPreviewCardView: View {
|
|
||||||
let card: BusinessCard
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: Design.Spacing.large) {
|
|
||||||
PhoneWidgetPreview(card: card)
|
|
||||||
WatchWidgetPreview(card: card)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WidgetsHeroCard: View {
|
|
||||||
var body: some View {
|
|
||||||
WidgetSurfaceCard {
|
|
||||||
Label("Widgets on iPhone and Watch", systemImage: "square.grid.2x2.fill")
|
|
||||||
.styled(.headingEmphasis)
|
|
||||||
|
|
||||||
Text("Share your card quickly from your home screen and your watch face.")
|
|
||||||
.styled(.subheading, emphasis: .secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WidgetsEmptyStateCard: View {
|
|
||||||
var body: some View {
|
|
||||||
WidgetSurfaceCard {
|
|
||||||
Label("No card selected", systemImage: "person.crop.rectangle")
|
|
||||||
.styled(.headingEmphasis)
|
|
||||||
|
|
||||||
Text("Select a business card from the Cards tab to preview widget layouts.")
|
|
||||||
.styled(.subheading, emphasis: .secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PhoneWidgetPreview: View {
|
|
||||||
let card: BusinessCard
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
WidgetSurfaceCard {
|
|
||||||
Label("Phone Widget", systemImage: "iphone")
|
|
||||||
.styled(.headingEmphasis)
|
|
||||||
|
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
|
||||||
QRCodeView(payload: card.vCardPayload)
|
|
||||||
.frame(width: Design.CardSize.widgetPhoneHeight, height: Design.CardSize.widgetPhoneHeight)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
|
||||||
Text(card.fullName)
|
|
||||||
.styled(.subheadingEmphasis)
|
|
||||||
.lineLimit(2)
|
|
||||||
Text(card.role)
|
|
||||||
.styled(.caption, emphasis: .secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
Text("Tap to share")
|
|
||||||
.styled(.caption2, emphasis: .tertiary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WatchWidgetPreview: View {
|
|
||||||
let card: BusinessCard
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
WidgetSurfaceCard {
|
|
||||||
Label("Watch Widget", systemImage: "applewatch")
|
|
||||||
.styled(.headingEmphasis)
|
|
||||||
|
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
|
||||||
QRCodeView(payload: card.vCardPayload)
|
|
||||||
.frame(width: Design.CardSize.widgetWatchSize, height: Design.CardSize.widgetWatchSize)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
|
||||||
Text("Ready to scan")
|
|
||||||
.styled(.subheadingEmphasis, emphasis: .secondary)
|
|
||||||
Text("Open on Apple Watch")
|
|
||||||
.styled(.caption, emphasis: .tertiary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WidgetSurfaceCard<Content: View>: View {
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
init(@ViewBuilder content: () -> Content) {
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
.padding(Design.Spacing.large)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(Color.AppBackground.secondary.opacity(Design.Opacity.almostFull))
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
|
||||||
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
|
||||||
}
|
|
||||||
.shadow(
|
|
||||||
color: Color.AppText.tertiary.opacity(Design.Opacity.subtle),
|
|
||||||
radius: Design.Shadow.radiusSmall,
|
|
||||||
x: Design.Shadow.offsetNone,
|
|
||||||
y: Design.Shadow.offsetSmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
WidgetsView()
|
|
||||||
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
|
||||||
}
|
|
||||||
49
CLOUDKIT_SCHEMA_GUARDRAILS.md
Normal file
49
CLOUDKIT_SCHEMA_GUARDRAILS.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# CloudKit Schema Guardrails (SwiftData)
|
||||||
|
|
||||||
|
Use this checklist before changing any `@Model` used by SwiftData + CloudKit.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
CloudKit-backed SwiftData is additive-first. Breaking schema edits can stop sync or cause migration failures on user devices with existing data.
|
||||||
|
|
||||||
|
## Safe Changes
|
||||||
|
|
||||||
|
- Add a new model type.
|
||||||
|
- Add a new property that is optional or has a default value.
|
||||||
|
- Add a new optional relationship.
|
||||||
|
- Add new computed properties (no storage impact).
|
||||||
|
- Add new methods/helpers/extensions.
|
||||||
|
|
||||||
|
## Risky / Avoid Changes
|
||||||
|
|
||||||
|
- Do not use `@Attribute(.unique)`.
|
||||||
|
- Do not remove stored properties that have shipped.
|
||||||
|
- Do not rename stored properties that have shipped.
|
||||||
|
- Do not change stored property types after shipping.
|
||||||
|
- Do not make optional relationships non-optional.
|
||||||
|
|
||||||
|
## If You Need a Breaking Change
|
||||||
|
|
||||||
|
1. Add a new property with a new name (keep old property).
|
||||||
|
2. Migrate values in app code lazily at runtime.
|
||||||
|
3. Ship at least one version with both old+new properties.
|
||||||
|
4. Only consider cleanup after all active users have migrated.
|
||||||
|
|
||||||
|
## Model Rules for This App
|
||||||
|
|
||||||
|
- Every stored property in `@Model` should be optional or have a default.
|
||||||
|
- Relationships must stay optional for CloudKit compatibility.
|
||||||
|
- Keep derived state computed (single source of truth).
|
||||||
|
|
||||||
|
## Pre-merge Checklist
|
||||||
|
|
||||||
|
- [ ] `xcodebuild ... build` succeeds.
|
||||||
|
- [ ] Existing sample/create/edit/delete flows still work.
|
||||||
|
- [ ] At least one device signed into iCloud verifies sync behavior.
|
||||||
|
- [ ] No schema-breaking edits were introduced.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
- First sync on a fresh install can take time.
|
||||||
|
- CloudKit conflicts are generally last-writer-wins.
|
||||||
|
- Keep mutation methods centralized in state/store types to reduce conflict bugs.
|
||||||
@ -107,6 +107,11 @@ Each field has:
|
|||||||
|
|
||||||
Cards and contacts are stored using SwiftData with CloudKit sync enabled. Your data automatically syncs across all your iPhones and iPads signed into the same iCloud account.
|
Cards and contacts are stored using SwiftData with CloudKit sync enabled. Your data automatically syncs across all your iPhones and iPads signed into the same iCloud account.
|
||||||
|
|
||||||
|
### CloudKit Schema Safety
|
||||||
|
|
||||||
|
Before changing any shipped SwiftData model schema, follow `CLOUDKIT_SCHEMA_GUARDRAILS.md`.
|
||||||
|
This is the project checklist for safe additive changes and migration-friendly patterns.
|
||||||
|
|
||||||
### iPhone to Watch Sync
|
### iPhone to Watch Sync
|
||||||
|
|
||||||
The iPhone app syncs card data to the paired Apple Watch via WatchConnectivity framework using `updateApplicationContext`. When you create, edit, or delete cards on your iPhone, the changes are pushed to your watch automatically.
|
The iPhone app syncs card data to the paired Apple Watch via WatchConnectivity framework using `updateApplicationContext`. When you create, edit, or delete cards on your iPhone, the changes are pushed to your watch automatically.
|
||||||
@ -120,7 +125,9 @@ The iPhone app syncs card data to the paired Apple Watch via WatchConnectivity f
|
|||||||
- **Clean Architecture**: Clear separation between Views, State, Services, and Models
|
- **Clean Architecture**: Clear separation between Views, State, Services, and Models
|
||||||
- **Views are dumb**: Presentation only, no business logic
|
- **Views are dumb**: Presentation only, no business logic
|
||||||
- **State is smart**: `@Observable` classes with all business logic
|
- **State is smart**: `@Observable` classes with all business logic
|
||||||
|
- **Centralized preferences**: `AppPreferencesStore` is the single source of truth for app-level settings (theme/debug flags)
|
||||||
- **Protocol-oriented**: Interfaces for services and stores
|
- **Protocol-oriented**: Interfaces for services and stores
|
||||||
|
- **Protocol-based providers**: `AppMetadataProviding` and `VCardPayloadProviding` keep services/state decoupled
|
||||||
- **SwiftData + CloudKit**: Persistent storage with iCloud sync
|
- **SwiftData + CloudKit**: Persistent storage with iCloud sync
|
||||||
- **WatchConnectivity**: iPhone to Apple Watch sync (NOT App Groups)
|
- **WatchConnectivity**: iPhone to Apple Watch sync (NOT App Groups)
|
||||||
- **One type per file**: Lean, maintainable files under 300 lines
|
- **One type per file**: Lean, maintainable files under 300 lines
|
||||||
@ -176,6 +183,7 @@ BusinessCard/
|
|||||||
└── Views/
|
└── Views/
|
||||||
├── Components/ # Reusable UI (ContactFieldPickerView, HeaderLayoutPickerView, etc.)
|
├── Components/ # Reusable UI (ContactFieldPickerView, HeaderLayoutPickerView, etc.)
|
||||||
├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.)
|
├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.)
|
||||||
|
├── Widgets/ # Widgets feature view + feature-specific components
|
||||||
└── [Feature].swift # Feature screens
|
└── [Feature].swift # Feature screens
|
||||||
|
|
||||||
BusinessCardWatch Watch App/ # watchOS app target (WatchConnectivity sync)
|
BusinessCardWatch Watch App/ # watchOS app target (WatchConnectivity sync)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user