diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift
index e827c84..8657f5d 100644
--- a/BusinessCard/BusinessCardApp.swift
+++ b/BusinessCard/BusinessCardApp.swift
@@ -9,9 +9,10 @@ struct BusinessCardApp: App {
init() {
let schema = Schema([BusinessCard.self, Contact.self, ContactField.self])
- let cloudKitDatabase: ModelConfiguration.CloudKitDatabase = .private(
- AppIdentifiers.cloudKitContainerIdentifier
- )
+ let cloudKitDatabase: ModelConfiguration.CloudKitDatabase =
+ AppIdentifiers.isCloudKitSyncEnabled
+ ? .private(AppIdentifiers.cloudKitContainerIdentifier)
+ : .none
// Register app theme for Bedrock semantic text/surface colors.
Theme.register(
@@ -22,39 +23,55 @@ struct BusinessCardApp: App {
)
Theme.register(border: AppBorder.self)
- // Primary strategy: App Group-backed persistent store with CloudKit sync.
+ // Primary strategy: App Group-backed persistent store.
var container: ModelContainer?
- if let appGroupURL = FileManager.default.containerURL(
- forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier
- ) {
- let storeURL = appGroupURL.appending(path: "BusinessCard.store")
+ func makeContainer(storeURL: URL, cloudKitDatabase: ModelConfiguration.CloudKitDatabase) -> ModelContainer? {
let config = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: cloudKitDatabase
)
-
- do {
- container = try ModelContainer(for: schema, configurations: [config])
- } catch {
- Design.debugLog("Failed to create container with App Group: \(error)")
+ return try? ModelContainer(for: schema, configurations: [config])
+ }
+
+ if let appGroupURL = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier
+ ) {
+ let storeURL = appGroupURL.appending(path: "BusinessCard.store")
+ container = makeContainer(storeURL: storeURL, cloudKitDatabase: cloudKitDatabase)
+ if container == nil {
+ Design.debugLog("Failed to create container in App Group.")
}
}
// Fallback: Default location (Application Support)
if container == nil {
let storeURL = URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
- let config = ModelConfiguration(
- schema: schema,
- url: storeURL,
- cloudKitDatabase: cloudKitDatabase
- )
+ container = makeContainer(storeURL: storeURL, cloudKitDatabase: cloudKitDatabase)
+ if container == nil {
+ Design.debugLog("Failed to create container in Application Support.")
+ }
+ }
+
+ // If CloudKit mode fails, force local-on-disk fallback before in-memory.
+ if container == nil && AppIdentifiers.isCloudKitSyncEnabled {
+ if let appGroupURL = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier
+ ) {
+ let storeURL = appGroupURL.appending(path: "BusinessCard.store")
+ container = makeContainer(storeURL: storeURL, cloudKitDatabase: .none)
+ if container == nil {
+ Design.debugLog("Failed to create local-only container in App Group.")
+ }
+ }
- do {
- container = try ModelContainer(for: schema, configurations: [config])
- } catch {
- Design.debugLog("Failed to create container in Application Support: \(error)")
+ if container == nil {
+ let storeURL = URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
+ container = makeContainer(storeURL: storeURL, cloudKitDatabase: .none)
+ if container == nil {
+ Design.debugLog("Failed to create local-only container in Application Support.")
+ }
}
}
diff --git a/BusinessCard/Configuration/AppIdentifiers.swift b/BusinessCard/Configuration/AppIdentifiers.swift
index 7012930..6e7b042 100644
--- a/BusinessCard/Configuration/AppIdentifiers.swift
+++ b/BusinessCard/Configuration/AppIdentifiers.swift
@@ -24,6 +24,13 @@ enum AppIdentifiers {
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
?? "iCloud.com.mbrucedogs.BusinessCard"
}()
+
+ /// Whether CloudKit-backed SwiftData sync should be enabled.
+ /// Defaults to false so local persistence works even when CloudKit model constraints are unmet.
+ static let isCloudKitSyncEnabled: Bool = {
+ let rawValue = (Bundle.main.object(forInfoDictionaryKey: "CloudKitSyncEnabled") as? String) ?? "NO"
+ return NSString(string: rawValue).boolValue
+ }()
/// App Clip domain for sharing URLs.
static let appClipDomain: String = {
diff --git a/BusinessCard/Configuration/Base.xcconfig b/BusinessCard/Configuration/Base.xcconfig
index 772ea2c..9811d9d 100644
--- a/BusinessCard/Configuration/Base.xcconfig
+++ b/BusinessCard/Configuration/Base.xcconfig
@@ -26,6 +26,7 @@ UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests
// Entitlement identifiers
APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME)
CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)
+CLOUDKIT_SYNC_ENABLED = NO
// =============================================================================
// APP CLIP CONFIGURATION
diff --git a/BusinessCard/Info.plist b/BusinessCard/Info.plist
index 5b7c325..063db3b 100644
--- a/BusinessCard/Info.plist
+++ b/BusinessCard/Info.plist
@@ -10,6 +10,8 @@
$(APP_GROUP_IDENTIFIER)
CloudKitContainerIdentifier
$(CLOUDKIT_CONTAINER_IDENTIFIER)
+ CloudKitSyncEnabled
+ $(CLOUDKIT_SYNC_ENABLED)
AppClipDomain
$(APPCLIP_DOMAIN)
UILaunchScreen
diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift
index b9b4781..120779a 100644
--- a/BusinessCard/Models/ContactFieldType.swift
+++ b/BusinessCard/Models/ContactFieldType.swift
@@ -151,7 +151,8 @@ extension ContactFieldType {
urlBuilder: { value in
let digits = value.filter { $0.isNumber || $0 == "+" }
return URL(string: "tel:\(digits)")
- }
+ },
+ displayValueFormatter: formatPhoneForDisplay
)
static let email = ContactFieldType(
@@ -676,6 +677,12 @@ nonisolated private func formatAddressForDisplay(_ value: String) -> String {
return trimmed
}
+/// Formats a phone number for display with lightweight US-first grouping.
+/// Falls back to a spaced international style for non-US lengths.
+nonisolated private func formatPhoneForDisplay(_ value: String) -> String {
+ PhoneNumberText.formatted(value)
+}
+
// MARK: - URL Helper Functions
nonisolated private func buildWebURL(_ value: String) -> URL? {
@@ -706,3 +713,134 @@ nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL
let username = trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
return URL(string: "https://\(webBase)/\(username)")
}
+
+// MARK: - Phone Text Utilities
+
+enum PhoneNumberText {
+ nonisolated static func normalizedForStorage(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ let hasLeadingPlus = trimmed.hasPrefix("+")
+ let digits = trimmed.filter(\.isNumber)
+
+ guard !digits.isEmpty else { return "" }
+ if hasLeadingPlus {
+ return "+\(digits)"
+ }
+ return digits
+ }
+
+ nonisolated static func isValid(_ value: String) -> Bool {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return false }
+
+ let plusCount = trimmed.filter { $0 == "+" }.count
+ if plusCount > 1 { return false }
+ if plusCount == 1 && !trimmed.hasPrefix("+") { return false }
+
+ let digits = trimmed.filter(\.isNumber)
+ return (7...15).contains(digits.count)
+ }
+
+ nonisolated static func formatted(_ raw: String) -> String {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "" }
+
+ let hasLeadingPlus = trimmed.hasPrefix("+")
+ let digits = trimmed.filter(\.isNumber)
+ guard !digits.isEmpty else { return hasLeadingPlus ? "+" : "" }
+
+ if hasLeadingPlus {
+ if digits.count == 11, digits.first == "1" {
+ return "+1 \(formatUSDigits(String(digits.dropFirst())))"
+ }
+ return "+" + formatInternationalDigits(digits)
+ }
+
+ if digits.count == 11, digits.first == "1" {
+ return "1 \(formatUSDigits(String(digits.dropFirst())))"
+ }
+ if digits.count <= 10 {
+ return formatUSDigits(digits)
+ }
+ return formatInternationalDigits(digits)
+ }
+
+ nonisolated private static func formatUSDigits(_ digits: String) -> String {
+ if digits.isEmpty { return "" }
+
+ if digits.count <= 3 {
+ return digits
+ }
+
+ let area = String(digits.prefix(3))
+ let remaining = String(digits.dropFirst(3))
+
+ if remaining.count <= 3 {
+ return "(\(area)) \(remaining)"
+ }
+
+ let prefix = String(remaining.prefix(3))
+ let line = String(remaining.dropFirst(3).prefix(4))
+ return "(\(area)) \(prefix)-\(line)"
+ }
+
+ nonisolated private static func formatInternationalDigits(_ digits: String) -> String {
+ guard !digits.isEmpty else { return "" }
+
+ if digits.count <= 3 {
+ return digits
+ }
+
+ var groups: [String] = []
+ var index = digits.startIndex
+
+ while index < digits.endIndex {
+ let next = digits.index(index, offsetBy: 3, limitedBy: digits.endIndex) ?? digits.endIndex
+ groups.append(String(digits[index.. String {
+ value
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ }
+
+ nonisolated static func isValid(_ value: String) -> Bool {
+ let trimmed = normalizedForStorage(value)
+ guard !trimmed.isEmpty, !trimmed.contains(" ") else { return false }
+
+ let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
+ guard parts.count == 2 else { return false }
+
+ let local = String(parts[0])
+ let domain = String(parts[1])
+
+ guard !local.isEmpty, !domain.isEmpty else { return false }
+ guard domain.contains("."), !domain.hasPrefix("."), !domain.hasSuffix(".") else { return false }
+ return true
+ }
+}
+
+enum WebLinkText {
+ nonisolated static func normalizedForStorage(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "" }
+ if let url = buildWebURL(trimmed) {
+ return url.absoluteString
+ }
+ return trimmed
+ }
+
+ nonisolated static func isValid(_ value: String) -> Bool {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, !trimmed.contains(" ") else { return false }
+ guard let url = buildWebURL(trimmed) else { return false }
+ return url.host != nil
+ }
+}
diff --git a/BusinessCard/State/ContactsStore.swift b/BusinessCard/State/ContactsStore.swift
index e64f4f8..9c7524a 100644
--- a/BusinessCard/State/ContactsStore.swift
+++ b/BusinessCard/State/ContactsStore.swift
@@ -149,7 +149,8 @@ final class ContactsStore: ContactTracking {
do {
try modelContext.save()
} catch {
- // Handle error silently for now
+ modelContext.rollback()
+ print("ContactsStore save failed: \(error)")
}
}
}
diff --git a/BusinessCard/Views/Sheets/AddContactSheet.swift b/BusinessCard/Views/Sheets/AddContactSheet.swift
index af883bc..15fe061 100644
--- a/BusinessCard/Views/Sheets/AddContactSheet.swift
+++ b/BusinessCard/Views/Sheets/AddContactSheet.swift
@@ -36,8 +36,30 @@ struct AddContactSheet: View {
@State private var notes = ""
private var canSave: Bool {
- !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
+ let hasName = !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ return hasName && !hasInvalidPhoneEntry && !hasInvalidEmailEntry && !hasInvalidLinkEntry
+ }
+
+ private var hasInvalidPhoneEntry: Bool {
+ phoneEntries.contains { entry in
+ let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return !trimmed.isEmpty && !PhoneNumberText.isValid(trimmed)
+ }
+ }
+
+ private var hasInvalidEmailEntry: Bool {
+ emailEntries.contains { entry in
+ let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return !trimmed.isEmpty && !EmailText.isValid(trimmed)
+ }
+ }
+
+ private var hasInvalidLinkEntry: Bool {
+ linkEntries.contains { entry in
+ let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return !trimmed.isEmpty && !WebLinkText.isValid(trimmed)
+ }
}
private var fullName: String {
@@ -83,7 +105,11 @@ struct AddContactSheet: View {
entry: $entry,
valuePlaceholder: "+1 (555) 123-4567",
labelSuggestions: ["Cell", "Work", "Home", "Main"],
- keyboardType: .phonePad
+ keyboardType: .phonePad,
+ autocapitalization: .never,
+ formatValue: PhoneNumberText.formatted,
+ isValueValid: PhoneNumberText.isValid,
+ validationMessage: String.localized("Enter a valid phone number")
)
}
.onDelete { indexSet in
@@ -107,7 +133,9 @@ struct AddContactSheet: View {
valuePlaceholder: "email@example.com",
labelSuggestions: ["Work", "Personal", "Other"],
keyboardType: .emailAddress,
- autocapitalization: .never
+ autocapitalization: .never,
+ isValueValid: EmailText.isValid,
+ validationMessage: String.localized("Enter a valid email address")
)
}
.onDelete { indexSet in
@@ -131,7 +159,9 @@ struct AddContactSheet: View {
valuePlaceholder: "https://example.com",
labelSuggestions: ["Website", "Portfolio", "LinkedIn", "Other"],
keyboardType: .URL,
- autocapitalization: .never
+ autocapitalization: .never,
+ isValueValid: WebLinkText.isValid,
+ validationMessage: String.localized("Enter a valid web link")
)
}
.onDelete { indexSet in
@@ -231,22 +261,49 @@ struct AddContactSheet: View {
var orderIndex = 0
// Add phone entries
- for entry in phoneEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- let field = ContactField(typeId: "phone", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
+ for entry in phoneEntries {
+ let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+ guard PhoneNumberText.isValid(trimmed) else { continue }
+
+ let field = ContactField(
+ typeId: "phone",
+ value: PhoneNumberText.normalizedForStorage(trimmed),
+ title: entry.label,
+ orderIndex: orderIndex
+ )
contactFields.append(field)
orderIndex += 1
}
// Add email entries
- for entry in emailEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- let field = ContactField(typeId: "email", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
+ for entry in emailEntries {
+ let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+ guard EmailText.isValid(trimmed) else { continue }
+
+ let field = ContactField(
+ typeId: "email",
+ value: EmailText.normalizedForStorage(trimmed),
+ title: entry.label,
+ orderIndex: orderIndex
+ )
contactFields.append(field)
orderIndex += 1
}
// Add link entries
- for entry in linkEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- let field = ContactField(typeId: "customLink", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
+ for entry in linkEntries {
+ let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+ guard WebLinkText.isValid(trimmed) else { continue }
+
+ let field = ContactField(
+ typeId: "customLink",
+ value: WebLinkText.normalizedForStorage(trimmed),
+ title: entry.label,
+ orderIndex: orderIndex
+ )
contactFields.append(field)
orderIndex += 1
}
@@ -327,31 +384,60 @@ private struct LabeledFieldRow: View {
let labelSuggestions: [String]
var keyboardType: UIKeyboardType = .default
var autocapitalization: TextInputAutocapitalization = .sentences
+ var formatValue: ((String) -> String)?
+ var isValueValid: ((String) -> Bool)?
+ var validationMessage: String?
+
+ private var trimmedValue: String {
+ entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private var showsValidationError: Bool {
+ guard let isValueValid else { return false }
+ guard !trimmedValue.isEmpty else { return false }
+ return !isValueValid(trimmedValue)
+ }
var body: some View {
- HStack(spacing: Design.Spacing.medium) {
- // Label picker
- Menu {
- ForEach(labelSuggestions, id: \.self) { suggestion in
- Button(suggestion) {
- entry.label = suggestion
+ VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
+ HStack(spacing: Design.Spacing.medium) {
+ // Label picker
+ Menu {
+ ForEach(labelSuggestions, id: \.self) { suggestion in
+ Button(suggestion) {
+ entry.label = suggestion
+ }
+ }
+ } label: {
+ HStack(spacing: Design.Spacing.xSmall) {
+ Text(entry.label)
+ .foregroundStyle(Color.accentColor)
+ Image(systemName: "chevron.up.chevron.down")
+ .typography(.caption2)
+ .foregroundStyle(Color.secondary)
}
}
- } label: {
- HStack(spacing: Design.Spacing.xSmall) {
- Text(entry.label)
- .foregroundStyle(Color.accentColor)
- Image(systemName: "chevron.up.chevron.down")
- .typography(.caption2)
- .foregroundStyle(Color.secondary)
- }
+ .frame(width: 80, alignment: .leading)
+
+ // Value field
+ TextField(valuePlaceholder, text: $entry.value)
+ .keyboardType(keyboardType)
+ .textInputAutocapitalization(autocapitalization)
+ .onChange(of: entry.value) { _, newValue in
+ guard let formatValue else { return }
+ let formatted = formatValue(newValue)
+ if formatted != newValue {
+ entry.value = formatted
+ }
+ }
}
- .frame(width: 80, alignment: .leading)
- // Value field
- TextField(valuePlaceholder, text: $entry.value)
- .keyboardType(keyboardType)
- .textInputAutocapitalization(autocapitalization)
+ if showsValidationError, let validationMessage {
+ Text(validationMessage)
+ .typography(.caption)
+ .foregroundStyle(Color.Accent.red)
+ .padding(.leading, 80 + Design.Spacing.medium)
+ }
}
}
}
diff --git a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift
index 43f07d0..3a0c18e 100644
--- a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift
+++ b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift
@@ -49,10 +49,31 @@ struct ContactFieldEditorSheet: View {
fieldType.id == "address"
}
+ private var isPhoneField: Bool {
+ fieldType.id == "phone"
+ }
+
+ private var isEmailField: Bool {
+ fieldType.id == "email"
+ }
+
+ private var isStrictURLField: Bool {
+ fieldType.id == "website" || fieldType.id == "customLink" || fieldType.id == "calendly"
+ }
+
private var isValid: Bool {
if isAddressField {
return postalAddress.hasValue
}
+ if isPhoneField {
+ return PhoneNumberText.isValid(value)
+ }
+ if isEmailField {
+ return EmailText.isValid(value)
+ }
+ if isStrictURLField {
+ return WebLinkText.isValid(value)
+ }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@@ -83,6 +104,33 @@ struct ContactFieldEditorSheet: View {
.keyboardType(fieldType.keyboardType)
.textInputAutocapitalization(fieldType.autocapitalization)
.textContentType(textContentType)
+ .onChange(of: value) { _, newValue in
+ guard isPhoneField else { return }
+ let formatted = PhoneNumberText.formatted(newValue)
+ if formatted != newValue {
+ value = formatted
+ }
+ }
+
+ if isPhoneField,
+ !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ !PhoneNumberText.isValid(value) {
+ Text(String.localized("Enter a valid phone number"))
+ .typography(.caption)
+ .foregroundStyle(Color.Accent.red)
+ } else if isEmailField,
+ !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ !EmailText.isValid(value) {
+ Text(String.localized("Enter a valid email address"))
+ .typography(.caption)
+ .foregroundStyle(Color.Accent.red)
+ } else if isStrictURLField,
+ !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ !WebLinkText.isValid(value) {
+ Text(String.localized("Enter a valid web link"))
+ .typography(.caption)
+ .foregroundStyle(Color.Accent.red)
+ }
Divider()
}
@@ -157,7 +205,14 @@ struct ContactFieldEditorSheet: View {
// Save postal address as JSON
onSave(postalAddress.encode(), title)
} else {
- onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title)
+ let valueToSave = isPhoneField
+ ? PhoneNumberText.normalizedForStorage(value)
+ : isEmailField
+ ? EmailText.normalizedForStorage(value)
+ : isStrictURLField
+ ? WebLinkText.normalizedForStorage(value)
+ : value.trimmingCharacters(in: .whitespacesAndNewlines)
+ onSave(valueToSave, title)
}
dismiss()
}