Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-10 21:03:12 -06:00
parent 540bd095dd
commit 3e9436791a
8 changed files with 361 additions and 54 deletions

View File

@ -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.")
}
}
}

View File

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

View File

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

View File

@ -10,6 +10,8 @@
<string>$(APP_GROUP_IDENTIFIER)</string>
<key>CloudKitContainerIdentifier</key>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
<key>CloudKitSyncEnabled</key>
<string>$(CLOUDKIT_SYNC_ENABLED)</string>
<key>AppClipDomain</key>
<string>$(APPCLIP_DOMAIN)</string>
<key>UILaunchScreen</key>

View File

@ -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..<next]))
index = next
}
return groups.joined(separator: " ")
}
}
enum EmailText {
nonisolated static func normalizedForStorage(_ value: String) -> 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
}
}

View File

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

View File

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

View File

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