From 540bd095dd7e16ad500ac1b17e51c6887f2f179c Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 10 Feb 2026 20:46:16 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Agents.md | 1 + BusinessCard/BusinessCardApp.swift | 10 +- BusinessCard/Models/BusinessCard.swift | 352 ++++++------------ .../Protocols/AppMetadataProviding.swift | 7 + .../Protocols/VCardPayloadProviding.swift | 11 + .../Services/BundleAppMetadataProvider.swift | 23 ++ BusinessCard/Services/VCardFileService.swift | 8 +- BusinessCard/State/AppPreferencesStore.swift | 42 +++ BusinessCard/State/AppState.swift | 24 +- BusinessCard/State/ContactsStore.swift | 6 +- BusinessCard/State/SettingsState.swift | 23 +- BusinessCard/Views/SettingsView.swift | 8 +- .../Components/WidgetPhonePreviewCard.swift | 33 ++ .../Components/WidgetPreviewCardView.swift | 13 + .../Components/WidgetSurfaceCard.swift | 30 ++ .../Components/WidgetWatchPreviewCard.swift | 28 ++ .../WidgetsEmptyStateCardView.swift | 14 + .../Components/WidgetsHeroCardView.swift | 14 + BusinessCard/Views/Widgets/WidgetsView.swift | 38 ++ BusinessCard/Views/WidgetsView.swift | 157 -------- CLOUDKIT_SCHEMA_GUARDRAILS.md | 49 +++ README.md | 8 + 22 files changed, 462 insertions(+), 437 deletions(-) create mode 100644 BusinessCard/Protocols/AppMetadataProviding.swift create mode 100644 BusinessCard/Protocols/VCardPayloadProviding.swift create mode 100644 BusinessCard/Services/BundleAppMetadataProvider.swift create mode 100644 BusinessCard/State/AppPreferencesStore.swift create mode 100644 BusinessCard/Views/Widgets/Components/WidgetPhonePreviewCard.swift create mode 100644 BusinessCard/Views/Widgets/Components/WidgetPreviewCardView.swift create mode 100644 BusinessCard/Views/Widgets/Components/WidgetSurfaceCard.swift create mode 100644 BusinessCard/Views/Widgets/Components/WidgetWatchPreviewCard.swift create mode 100644 BusinessCard/Views/Widgets/Components/WidgetsEmptyStateCardView.swift create mode 100644 BusinessCard/Views/Widgets/Components/WidgetsHeroCardView.swift create mode 100644 BusinessCard/Views/Widgets/WidgetsView.swift delete mode 100644 BusinessCard/Views/WidgetsView.swift create mode 100644 CLOUDKIT_SCHEMA_GUARDRAILS.md diff --git a/Agents.md b/Agents.md index 9a15adc..c408d42 100644 --- a/Agents.md +++ b/Agents.md @@ -347,6 +347,7 @@ If SwiftData is configured to use CloudKit: - Never use `@Attribute(.unique)`. - Model properties must have default values or be optional. - All relationships must be marked optional. +- Before changing any shipped `@Model` schema, follow `CLOUDKIT_SCHEMA_GUARDRAILS.md`. ## Model Design: Single Source of Truth diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift index 370cb68..e827c84 100644 --- a/BusinessCard/BusinessCardApp.swift +++ b/BusinessCard/BusinessCardApp.swift @@ -9,6 +9,9 @@ struct BusinessCardApp: App { init() { 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. Theme.register( @@ -19,8 +22,7 @@ struct BusinessCardApp: App { ) Theme.register(border: AppBorder.self) - // Primary strategy: App Group for watch sync (without CloudKit for now) - // CloudKit can be enabled once properly configured in Xcode + // Primary strategy: App Group-backed persistent store with CloudKit sync. var container: ModelContainer? if let appGroupURL = FileManager.default.containerURL( @@ -30,7 +32,7 @@ struct BusinessCardApp: App { let config = ModelConfiguration( schema: schema, url: storeURL, - cloudKitDatabase: .none // Disable CloudKit until properly configured + cloudKitDatabase: cloudKitDatabase ) do { @@ -46,7 +48,7 @@ struct BusinessCardApp: App { let config = ModelConfiguration( schema: schema, url: storeURL, - cloudKitDatabase: .none + cloudKitDatabase: cloudKitDatabase ) do { diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift index a47e99e..8c3c3c1 100644 --- a/BusinessCard/Models/BusinessCard.swift +++ b/BusinessCard/Models/BusinessCard.swift @@ -222,133 +222,16 @@ final class BusinessCard { .joined(separator: " ") } + /// Display-friendly preferred name fallback. + var displayName: String { + if !preferredName.isEmpty { return preferredName } + if !firstName.isEmpty { return firstName } + return fullName + } + @MainActor var vCardPayload: String { - var lines = [ - "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") + buildVCardPayload(includePhotoData: nil) } /// 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. @MainActor var vCardFilePayload: String { - var lines = [ - "BEGIN:VCARD", - "VERSION:3.0" - ] - - // N: Structured name - REQUIRED for proper contact import - let structuredName = [lastName, firstName, middleName, prefix, suffix] - .map { escapeVCardValue($0) } + buildVCardPayload(includePhotoData: photoData) + } + + private var vCardStructuredName: String { + [lastName, firstName, middleName, prefix, suffix] + .map(escapeVCardValue) .joined(separator: ";") - lines.append("N:\(structuredName)") - - // FN: Formatted name - let formattedName = vCardName - lines.append("FN:\(escapeVCardValue(formattedName))") - - // PHOTO: Embedded profile photo as base64-encoded JPEG - if let photoData { - let base64Photo = photoData.base64EncodedString() - lines.append("PHOTO;ENCODING=b;TYPE=JPEG:\(base64Photo)") + } + + private var vCardNoteEntries: [String] { + 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)") } + return notes + } + + @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 { lines.append("NICKNAME:\(escapeVCardValue(preferredName))") } - - // ORG: Organization + if !company.isEmpty { if !department.isEmpty { lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))") @@ -390,103 +278,99 @@ final class BusinessCard { 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": - 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))") - } - } + lines.append(contentsOf: vCardLines(for: field, value: 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")))") + + if !vCardNoteEntries.isEmpty { + lines.append("NOTE:\(escapeVCardValue(vCardNoteEntries.joined(separator: "\\n")))") } - + lines.append("END:VCARD") 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 private func escapeVCardValue(_ value: String) -> String { value @@ -497,6 +381,8 @@ final class BusinessCard { } } +extension BusinessCard: VCardPayloadProviding {} + extension BusinessCard { @MainActor static func createSamples(in context: ModelContext) { diff --git a/BusinessCard/Protocols/AppMetadataProviding.swift b/BusinessCard/Protocols/AppMetadataProviding.swift new file mode 100644 index 0000000..7db70d5 --- /dev/null +++ b/BusinessCard/Protocols/AppMetadataProviding.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol AppMetadataProviding { + var appName: String { get } + var appVersion: String { get } + var buildNumber: String { get } +} diff --git a/BusinessCard/Protocols/VCardPayloadProviding.swift b/BusinessCard/Protocols/VCardPayloadProviding.swift new file mode 100644 index 0000000..c5cbdce --- /dev/null +++ b/BusinessCard/Protocols/VCardPayloadProviding.swift @@ -0,0 +1,11 @@ +import Foundation + +protocol VCardPayloadProviding { + var vCardName: String { get } + + @MainActor + var vCardPayload: String { get } + + @MainActor + var vCardFilePayload: String { get } +} diff --git a/BusinessCard/Services/BundleAppMetadataProvider.swift b/BusinessCard/Services/BundleAppMetadataProvider.swift new file mode 100644 index 0000000..4dfc310 --- /dev/null +++ b/BusinessCard/Services/BundleAppMetadataProvider.swift @@ -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" + } +} diff --git a/BusinessCard/Services/VCardFileService.swift b/BusinessCard/Services/VCardFileService.swift index b497fc6..0fb5016 100644 --- a/BusinessCard/Services/VCardFileService.swift +++ b/BusinessCard/Services/VCardFileService.swift @@ -7,15 +7,15 @@ struct VCardFileService { /// Creates a temporary .vcf file for the given card and returns its URL. /// The file includes the embedded profile photo if available. @MainActor - func createVCardFile(for card: BusinessCard) throws -> URL { - let payload = card.vCardFilePayload + func createVCardFile(for payloadProvider: some VCardPayloadProviding) throws -> URL { + let payload = payloadProvider.vCardFilePayload // 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 // Write to temp directory with .vcf extension - let tempURL = FileManager.default.temporaryDirectory + let tempURL = URL.temporaryDirectory .appending(path: "\(filename).vcf") // Remove existing file if present diff --git a/BusinessCard/State/AppPreferencesStore.swift b/BusinessCard/State/AppPreferencesStore.swift new file mode 100644 index 0000000..675f990 --- /dev/null +++ b/BusinessCard/State/AppPreferencesStore.swift @@ -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 + } + } +} diff --git a/BusinessCard/State/AppState.swift b/BusinessCard/State/AppState.swift index 655c7e2..cd9fed4 100644 --- a/BusinessCard/State/AppState.swift +++ b/BusinessCard/State/AppState.swift @@ -20,34 +20,26 @@ enum AppAppearance: String, CaseIterable, Sendable { @Observable @MainActor final class AppState { - private enum DefaultsKey { - static let appearance = "appAppearance" - } - var selectedTab: AppTab = .cards var cardStore: CardStore var contactsStore: ContactsStore + let preferences: AppPreferencesStore let shareLinkService: ShareLinkProviding - var appearance: AppAppearance { - didSet { - UserDefaults.standard.set(appearance.rawValue, forKey: DefaultsKey.appearance) - } - } var preferredColorScheme: ColorScheme? { - appearance.preferredColorScheme + preferences.preferredColorScheme + } + + var appearance: AppAppearance { + get { preferences.appearance } + set { preferences.appearance = newValue } } init(modelContext: ModelContext) { self.cardStore = CardStore(modelContext: modelContext) self.contactsStore = ContactsStore(modelContext: modelContext) + self.preferences = AppPreferencesStore() 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) Task { diff --git a/BusinessCard/State/ContactsStore.swift b/BusinessCard/State/ContactsStore.swift index c59f7b0..e64f4f8 100644 --- a/BusinessCard/State/ContactsStore.swift +++ b/BusinessCard/State/ContactsStore.swift @@ -140,9 +140,9 @@ final class ContactsStore: ContactTracking { } func relativeShareDate(for contact: Contact) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .short - return formatter.localizedString(for: contact.lastSharedDate, relativeTo: .now) + contact.lastSharedDate.formatted( + .relative(presentation: .named, unitsStyle: .abbreviated) + ) } private func saveContext() { diff --git a/BusinessCard/State/SettingsState.swift b/BusinessCard/State/SettingsState.swift index 5695107..9accc66 100644 --- a/BusinessCard/State/SettingsState.swift +++ b/BusinessCard/State/SettingsState.swift @@ -11,24 +11,23 @@ import Observation @Observable @MainActor final class SettingsState { + private let metadataProvider: AppMetadataProviding // MARK: - App Info /// The app's display name. var appName: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String - ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String - ?? "BusinessCard" + metadataProvider.appName } /// The app's version string (e.g., "1.0.0"). var appVersion: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" + metadataProvider.appVersion } /// The app's build number. var buildNumber: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" + metadataProvider.buildNumber } /// Combined version and build string for display. @@ -36,17 +35,9 @@ final class SettingsState { "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 - init() {} + init(metadataProvider: AppMetadataProviding = BundleAppMetadataProvider()) { + self.metadataProvider = metadataProvider + } } diff --git a/BusinessCard/Views/SettingsView.swift b/BusinessCard/Views/SettingsView.swift index 13512fc..9e97737 100644 --- a/BusinessCard/Views/SettingsView.swift +++ b/BusinessCard/Views/SettingsView.swift @@ -63,8 +63,8 @@ struct SettingsView: View { ("Dark", AppAppearance.dark) ], selection: Binding( - get: { appState.appearance }, - set: { appState.appearance = $0 } + get: { appState.preferences.appearance }, + set: { appState.preferences.appearance = $0 } ), accentColor: AppThemeAccent.primary ) @@ -161,8 +161,8 @@ struct SettingsView: View { title: "Enable Debug Premium", subtitle: "Unlock all premium features for testing", isOn: Binding( - get: { settingsState.isDebugPremiumEnabled }, - set: { settingsState.isDebugPremiumEnabled = $0 } + get: { appState.preferences.isDebugPremiumEnabled }, + set: { appState.preferences.isDebugPremiumEnabled = $0 } ), accentColor: AppStatus.warning ) diff --git a/BusinessCard/Views/Widgets/Components/WidgetPhonePreviewCard.swift b/BusinessCard/Views/Widgets/Components/WidgetPhonePreviewCard.swift new file mode 100644 index 0000000..497935d --- /dev/null +++ b/BusinessCard/Views/Widgets/Components/WidgetPhonePreviewCard.swift @@ -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) + } + } + } +} diff --git a/BusinessCard/Views/Widgets/Components/WidgetPreviewCardView.swift b/BusinessCard/Views/Widgets/Components/WidgetPreviewCardView.swift new file mode 100644 index 0000000..f95112c --- /dev/null +++ b/BusinessCard/Views/Widgets/Components/WidgetPreviewCardView.swift @@ -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) + } + } +} diff --git a/BusinessCard/Views/Widgets/Components/WidgetSurfaceCard.swift b/BusinessCard/Views/Widgets/Components/WidgetSurfaceCard.swift new file mode 100644 index 0000000..57ea280 --- /dev/null +++ b/BusinessCard/Views/Widgets/Components/WidgetSurfaceCard.swift @@ -0,0 +1,30 @@ +import SwiftUI +import Bedrock + +struct WidgetSurfaceCard: 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 + ) + } +} diff --git a/BusinessCard/Views/Widgets/Components/WidgetWatchPreviewCard.swift b/BusinessCard/Views/Widgets/Components/WidgetWatchPreviewCard.swift new file mode 100644 index 0000000..65baed9 --- /dev/null +++ b/BusinessCard/Views/Widgets/Components/WidgetWatchPreviewCard.swift @@ -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) + } + } + } +} diff --git a/BusinessCard/Views/Widgets/Components/WidgetsEmptyStateCardView.swift b/BusinessCard/Views/Widgets/Components/WidgetsEmptyStateCardView.swift new file mode 100644 index 0000000..6d86d00 --- /dev/null +++ b/BusinessCard/Views/Widgets/Components/WidgetsEmptyStateCardView.swift @@ -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) + } + } +} diff --git a/BusinessCard/Views/Widgets/Components/WidgetsHeroCardView.swift b/BusinessCard/Views/Widgets/Components/WidgetsHeroCardView.swift new file mode 100644 index 0000000..074d3f3 --- /dev/null +++ b/BusinessCard/Views/Widgets/Components/WidgetsHeroCardView.swift @@ -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) + } + } +} diff --git a/BusinessCard/Views/Widgets/WidgetsView.swift b/BusinessCard/Views/Widgets/WidgetsView.swift new file mode 100644 index 0000000..304116d --- /dev/null +++ b/BusinessCard/Views/Widgets/WidgetsView.swift @@ -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() +} diff --git a/BusinessCard/Views/WidgetsView.swift b/BusinessCard/Views/WidgetsView.swift deleted file mode 100644 index 0e82629..0000000 --- a/BusinessCard/Views/WidgetsView.swift +++ /dev/null @@ -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: 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)) -} diff --git a/CLOUDKIT_SCHEMA_GUARDRAILS.md b/CLOUDKIT_SCHEMA_GUARDRAILS.md new file mode 100644 index 0000000..752e1d3 --- /dev/null +++ b/CLOUDKIT_SCHEMA_GUARDRAILS.md @@ -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. diff --git a/README.md b/README.md index 9e374c1..0fa1ba2 100644 --- a/README.md +++ b/README.md @@ -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. +### 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 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 - **Views are dumb**: Presentation only, no 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-based providers**: `AppMetadataProviding` and `VCardPayloadProviding` keep services/state decoupled - **SwiftData + CloudKit**: Persistent storage with iCloud sync - **WatchConnectivity**: iPhone to Apple Watch sync (NOT App Groups) - **One type per file**: Lean, maintainable files under 300 lines @@ -176,6 +183,7 @@ BusinessCard/ └── Views/ ├── Components/ # Reusable UI (ContactFieldPickerView, HeaderLayoutPickerView, etc.) ├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.) + ├── Widgets/ # Widgets feature view + feature-specific components └── [Feature].swift # Feature screens BusinessCardWatch Watch App/ # watchOS app target (WatchConnectivity sync)