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)`.
- 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

View File

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

View File

@ -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) }
.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)")
buildVCardPayload(includePhotoData: photoData)
}
private var vCardStructuredName: String {
[lastName, firstName, middleName, prefix, suffix]
.map(escapeVCardValue)
.joined(separator: ";")
}
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))")
@ -391,102 +279,98 @@ final class BusinessCard {
}
}
// 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) {

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

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

View File

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

View File

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

View File

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

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.
### 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)