Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
0b71f5ea65
commit
540bd095dd
@ -347,6 +347,7 @@ If SwiftData is configured to use CloudKit:
|
||||
- Never use `@Attribute(.unique)`.
|
||||
- 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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
]
|
||||
buildVCardPayload(includePhotoData: photoData)
|
||||
}
|
||||
|
||||
// N: Structured name - REQUIRED for proper contact import
|
||||
let structuredName = [lastName, firstName, middleName, prefix, suffix]
|
||||
.map { escapeVCardValue($0) }
|
||||
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))")
|
||||
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
|
||||
}
|
||||
|
||||
// PHOTO: Embedded profile photo as base64-encoded JPEG
|
||||
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 {
|
||||
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) {
|
||||
|
||||
7
BusinessCard/Protocols/AppMetadataProviding.swift
Normal file
7
BusinessCard/Protocols/AppMetadataProviding.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
protocol AppMetadataProviding {
|
||||
var appName: String { get }
|
||||
var appVersion: String { get }
|
||||
var buildNumber: String { get }
|
||||
}
|
||||
11
BusinessCard/Protocols/VCardPayloadProviding.swift
Normal file
11
BusinessCard/Protocols/VCardPayloadProviding.swift
Normal file
@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
protocol VCardPayloadProviding {
|
||||
var vCardName: String { get }
|
||||
|
||||
@MainActor
|
||||
var vCardPayload: String { get }
|
||||
|
||||
@MainActor
|
||||
var vCardFilePayload: String { get }
|
||||
}
|
||||
23
BusinessCard/Services/BundleAppMetadataProvider.swift
Normal file
23
BusinessCard/Services/BundleAppMetadataProvider.swift
Normal file
@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
struct BundleAppMetadataProvider: AppMetadataProviding {
|
||||
private let bundle: Bundle
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
self.bundle = bundle
|
||||
}
|
||||
|
||||
var appName: String {
|
||||
bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||
?? "BusinessCard"
|
||||
}
|
||||
|
||||
var appVersion: String {
|
||||
bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
||||
}
|
||||
|
||||
var buildNumber: String {
|
||||
bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
}
|
||||
}
|
||||
@ -7,15 +7,15 @@ struct VCardFileService {
|
||||
/// Creates a temporary .vcf file for the given card and returns its URL.
|
||||
/// 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
|
||||
|
||||
42
BusinessCard/State/AppPreferencesStore.swift
Normal file
42
BusinessCard/State/AppPreferencesStore.swift
Normal file
@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppPreferencesStore {
|
||||
private enum Keys {
|
||||
static let appearance = "appAppearance"
|
||||
static let debugPremiumEnabled = "debugPremiumEnabled"
|
||||
}
|
||||
|
||||
private let userDefaults: UserDefaults
|
||||
|
||||
var appearance: AppAppearance {
|
||||
didSet {
|
||||
userDefaults.set(appearance.rawValue, forKey: Keys.appearance)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var isDebugPremiumEnabled: Bool {
|
||||
get { userDefaults.bool(forKey: Keys.debugPremiumEnabled) }
|
||||
set { userDefaults.set(newValue, forKey: Keys.debugPremiumEnabled) }
|
||||
}
|
||||
#endif
|
||||
|
||||
var preferredColorScheme: ColorScheme? {
|
||||
appearance.preferredColorScheme
|
||||
}
|
||||
|
||||
init(userDefaults: UserDefaults = .standard) {
|
||||
self.userDefaults = userDefaults
|
||||
|
||||
if let rawValue = userDefaults.string(forKey: Keys.appearance),
|
||||
let savedAppearance = AppAppearance(rawValue: rawValue) {
|
||||
self.appearance = savedAppearance
|
||||
} else {
|
||||
self.appearance = .system
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,34 +20,26 @@ enum AppAppearance: String, CaseIterable, Sendable {
|
||||
@Observable
|
||||
@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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct WidgetPhonePreviewCard: View {
|
||||
let card: BusinessCard
|
||||
|
||||
var body: some View {
|
||||
WidgetSurfaceCard {
|
||||
Label("Phone Widget", systemImage: "iphone")
|
||||
.styled(.headingEmphasis)
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
QRCodeView(payload: card.vCardPayload)
|
||||
.frame(width: Design.CardSize.widgetPhoneHeight, height: Design.CardSize.widgetPhoneHeight)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text(card.fullName)
|
||||
.styled(.subheadingEmphasis)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(card.role)
|
||||
.styled(.caption, emphasis: .secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text("Tap to share")
|
||||
.styled(.caption2, emphasis: .tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct WidgetPreviewCardView: View {
|
||||
let card: BusinessCard
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
WidgetPhonePreviewCard(card: card)
|
||||
WidgetWatchPreviewCard(card: card)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct WidgetSurfaceCard<Content: View>: View {
|
||||
let content: Content
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
content
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.AppBackground.secondary.opacity(Design.Opacity.almostFull))
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
||||
}
|
||||
.shadow(
|
||||
color: Color.AppText.tertiary.opacity(Design.Opacity.subtle),
|
||||
radius: Design.Shadow.radiusSmall,
|
||||
x: Design.Shadow.offsetNone,
|
||||
y: Design.Shadow.offsetSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct WidgetWatchPreviewCard: View {
|
||||
let card: BusinessCard
|
||||
|
||||
var body: some View {
|
||||
WidgetSurfaceCard {
|
||||
Label("Watch Widget", systemImage: "applewatch")
|
||||
.styled(.headingEmphasis)
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
QRCodeView(payload: card.vCardPayload)
|
||||
.frame(width: Design.CardSize.widgetWatchSize, height: Design.CardSize.widgetWatchSize)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text("Ready to scan")
|
||||
.styled(.subheadingEmphasis, emphasis: .secondary)
|
||||
|
||||
Text("Open on Apple Watch")
|
||||
.styled(.caption, emphasis: .tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct WidgetsEmptyStateCardView: View {
|
||||
var body: some View {
|
||||
WidgetSurfaceCard {
|
||||
Label("No card selected", systemImage: "person.crop.rectangle")
|
||||
.styled(.headingEmphasis)
|
||||
|
||||
Text("Select a business card from the Cards tab to preview widget layouts.")
|
||||
.styled(.subheading, emphasis: .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct WidgetsHeroCardView: View {
|
||||
var body: some View {
|
||||
WidgetSurfaceCard {
|
||||
Label("Widgets on iPhone and Watch", systemImage: "square.grid.2x2.fill")
|
||||
.styled(.headingEmphasis)
|
||||
|
||||
Text("Share your card quickly from your home screen and your watch face.")
|
||||
.styled(.subheading, emphasis: .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
38
BusinessCard/Views/Widgets/WidgetsView.swift
Normal file
38
BusinessCard/Views/Widgets/WidgetsView.swift
Normal file
@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct WidgetsView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
WidgetsHeroCardView()
|
||||
|
||||
if let card = appState.cardStore.selectedCard {
|
||||
WidgetPreviewCardView(card: card)
|
||||
} else {
|
||||
WidgetsEmptyStateCardView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
.navigationTitle(String.localized("Widgets"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WidgetsView()
|
||||
}
|
||||
@ -1,157 +0,0 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import SwiftData
|
||||
|
||||
struct WidgetsView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
WidgetsHeroCard()
|
||||
|
||||
if let card = appState.cardStore.selectedCard {
|
||||
WidgetPreviewCardView(card: card)
|
||||
} else {
|
||||
WidgetsEmptyStateCard()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
.navigationTitle(String.localized("Widgets"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WidgetPreviewCardView: View {
|
||||
let card: BusinessCard
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
PhoneWidgetPreview(card: card)
|
||||
WatchWidgetPreview(card: card)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WidgetsHeroCard: View {
|
||||
var body: some View {
|
||||
WidgetSurfaceCard {
|
||||
Label("Widgets on iPhone and Watch", systemImage: "square.grid.2x2.fill")
|
||||
.styled(.headingEmphasis)
|
||||
|
||||
Text("Share your card quickly from your home screen and your watch face.")
|
||||
.styled(.subheading, emphasis: .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WidgetsEmptyStateCard: View {
|
||||
var body: some View {
|
||||
WidgetSurfaceCard {
|
||||
Label("No card selected", systemImage: "person.crop.rectangle")
|
||||
.styled(.headingEmphasis)
|
||||
|
||||
Text("Select a business card from the Cards tab to preview widget layouts.")
|
||||
.styled(.subheading, emphasis: .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PhoneWidgetPreview: View {
|
||||
let card: BusinessCard
|
||||
|
||||
var body: some View {
|
||||
WidgetSurfaceCard {
|
||||
Label("Phone Widget", systemImage: "iphone")
|
||||
.styled(.headingEmphasis)
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
QRCodeView(payload: card.vCardPayload)
|
||||
.frame(width: Design.CardSize.widgetPhoneHeight, height: Design.CardSize.widgetPhoneHeight)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text(card.fullName)
|
||||
.styled(.subheadingEmphasis)
|
||||
.lineLimit(2)
|
||||
Text(card.role)
|
||||
.styled(.caption, emphasis: .secondary)
|
||||
.lineLimit(1)
|
||||
Text("Tap to share")
|
||||
.styled(.caption2, emphasis: .tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchWidgetPreview: View {
|
||||
let card: BusinessCard
|
||||
|
||||
var body: some View {
|
||||
WidgetSurfaceCard {
|
||||
Label("Watch Widget", systemImage: "applewatch")
|
||||
.styled(.headingEmphasis)
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
QRCodeView(payload: card.vCardPayload)
|
||||
.frame(width: Design.CardSize.widgetWatchSize, height: Design.CardSize.widgetWatchSize)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text("Ready to scan")
|
||||
.styled(.subheadingEmphasis, emphasis: .secondary)
|
||||
Text("Open on Apple Watch")
|
||||
.styled(.caption, emphasis: .tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WidgetSurfaceCard<Content: View>: View {
|
||||
let content: Content
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
content
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.AppBackground.secondary.opacity(Design.Opacity.almostFull))
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
||||
}
|
||||
.shadow(
|
||||
color: Color.AppText.tertiary.opacity(Design.Opacity.subtle),
|
||||
radius: Design.Shadow.radiusSmall,
|
||||
x: Design.Shadow.offsetNone,
|
||||
y: Design.Shadow.offsetSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WidgetsView()
|
||||
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||
}
|
||||
49
CLOUDKIT_SCHEMA_GUARDRAILS.md
Normal file
49
CLOUDKIT_SCHEMA_GUARDRAILS.md
Normal file
@ -0,0 +1,49 @@
|
||||
# CloudKit Schema Guardrails (SwiftData)
|
||||
|
||||
Use this checklist before changing any `@Model` used by SwiftData + CloudKit.
|
||||
|
||||
## Why
|
||||
|
||||
CloudKit-backed SwiftData is additive-first. Breaking schema edits can stop sync or cause migration failures on user devices with existing data.
|
||||
|
||||
## Safe Changes
|
||||
|
||||
- Add a new model type.
|
||||
- Add a new property that is optional or has a default value.
|
||||
- Add a new optional relationship.
|
||||
- Add new computed properties (no storage impact).
|
||||
- Add new methods/helpers/extensions.
|
||||
|
||||
## Risky / Avoid Changes
|
||||
|
||||
- Do not use `@Attribute(.unique)`.
|
||||
- Do not remove stored properties that have shipped.
|
||||
- Do not rename stored properties that have shipped.
|
||||
- Do not change stored property types after shipping.
|
||||
- Do not make optional relationships non-optional.
|
||||
|
||||
## If You Need a Breaking Change
|
||||
|
||||
1. Add a new property with a new name (keep old property).
|
||||
2. Migrate values in app code lazily at runtime.
|
||||
3. Ship at least one version with both old+new properties.
|
||||
4. Only consider cleanup after all active users have migrated.
|
||||
|
||||
## Model Rules for This App
|
||||
|
||||
- Every stored property in `@Model` should be optional or have a default.
|
||||
- Relationships must stay optional for CloudKit compatibility.
|
||||
- Keep derived state computed (single source of truth).
|
||||
|
||||
## Pre-merge Checklist
|
||||
|
||||
- [ ] `xcodebuild ... build` succeeds.
|
||||
- [ ] Existing sample/create/edit/delete flows still work.
|
||||
- [ ] At least one device signed into iCloud verifies sync behavior.
|
||||
- [ ] No schema-breaking edits were introduced.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- First sync on a fresh install can take time.
|
||||
- CloudKit conflicts are generally last-writer-wins.
|
||||
- Keep mutation methods centralized in state/store types to reduce conflict bugs.
|
||||
@ -107,6 +107,11 @@ Each field has:
|
||||
|
||||
Cards and contacts are stored using SwiftData with CloudKit sync enabled. Your data automatically syncs across all your iPhones and iPads signed into the same iCloud account.
|
||||
|
||||
### 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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user