From 3e9436791a58f0449b07eea73110975952008a24 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 10 Feb 2026 21:03:12 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard/BusinessCardApp.swift | 61 +++++--- .../Configuration/AppIdentifiers.swift | 7 + BusinessCard/Configuration/Base.xcconfig | 1 + BusinessCard/Info.plist | 2 + BusinessCard/Models/ContactFieldType.swift | 140 ++++++++++++++++- BusinessCard/State/ContactsStore.swift | 3 +- .../Views/Sheets/AddContactSheet.swift | 144 ++++++++++++++---- .../Sheets/ContactFieldEditorSheet.swift | 57 ++++++- 8 files changed, 361 insertions(+), 54 deletions(-) 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() }