Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-10 20:46:16 -06:00
parent 0b71f5ea65
commit 540bd095dd
22 changed files with 462 additions and 437 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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) {

View File

@ -0,0 +1,7 @@
import Foundation
protocol AppMetadataProviding {
var appName: String { get }
var appVersion: String { get }
var buildNumber: String { get }
}

View File

@ -0,0 +1,11 @@
import Foundation
protocol VCardPayloadProviding {
var vCardName: String { get }
@MainActor
var vCardPayload: String { get }
@MainActor
var vCardFilePayload: String { get }
}

View 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"
}
}

View File

@ -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

View 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
}
}
}

View File

@ -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 {

View File

@ -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() {

View File

@ -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
}
} }

View File

@ -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
) )

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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
)
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View 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()
}

View File

@ -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))
}

View 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.

View File

@ -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)