diff --git a/BusinessCard/Models/Contact.swift b/BusinessCard/Models/Contact.swift index 7363ec7..a3c12bc 100644 --- a/BusinessCard/Models/Contact.swift +++ b/BusinessCard/Models/Contact.swift @@ -227,7 +227,7 @@ extension Contact { let postalAddress = parseVCardADR(addressPart) if postalAddress.hasValue { - let field = ContactField(typeId: "address", value: postalAddress.toJSON(), title: typeLabel, orderIndex: fieldIndex) + let field = ContactField(typeId: "address", value: postalAddress.encode(), title: typeLabel, orderIndex: fieldIndex) field.contact = contact contact.contactFields?.append(field) fieldIndex += 1 diff --git a/BusinessCard/Models/ContactField.swift b/BusinessCard/Models/ContactField.swift index 5f5d799..b66edd3 100644 --- a/BusinessCard/Models/ContactField.swift +++ b/BusinessCard/Models/ContactField.swift @@ -98,7 +98,7 @@ final class ContactField { guard typeId == "address" else { return nil } // Parse as structured PostalAddress - if let address = PostalAddress.fromJSON(value), address.hasValue { + if let address = PostalAddress.decode(from: value), address.hasValue { return address } @@ -108,7 +108,7 @@ final class ContactField { /// Sets the postal address (stores as JSON) @MainActor func setPostalAddress(_ address: PostalAddress) { - self.value = address.toJSON() + self.value = address.encode() } } diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift index b3d6120..b9b4781 100644 --- a/BusinessCard/Models/ContactFieldType.swift +++ b/BusinessCard/Models/ContactFieldType.swift @@ -193,7 +193,7 @@ extension ContactFieldType { urlBuilder: { value in // Try to parse as PostalAddress JSON first for proper formatting let searchQuery: String - if let address = PostalAddress.fromJSON(value) { + if let address = PostalAddress.decode(from: value) { searchQuery = address.singleLineString } else { searchQuery = value @@ -668,7 +668,7 @@ nonisolated private func formatAddressForDisplay(_ value: String) -> String { guard !trimmed.isEmpty else { return value } // Parse as structured PostalAddress - if let address = PostalAddress.fromJSON(trimmed), address.hasValue { + if let address = PostalAddress.decode(from: trimmed), address.hasValue { return address.formattedString } diff --git a/BusinessCard/Models/PostalAddress.swift b/BusinessCard/Models/PostalAddress.swift index 007641d..5017f01 100644 --- a/BusinessCard/Models/PostalAddress.swift +++ b/BusinessCard/Models/PostalAddress.swift @@ -1,8 +1,8 @@ import Foundation /// A structured postal address for VCard compatibility and locale-aware formatting -/// This struct is Sendable and all properties/methods are explicitly nonisolated for use in any context -struct PostalAddress: Hashable, Sendable { +/// Uses @preconcurrency Codable to allow nonisolated encoding/decoding in Swift 6 +struct PostalAddress: Hashable, Sendable, @preconcurrency Codable { var street: String = "" // Street address line 1 (can include apt/suite) var street2: String = "" // Street address line 2 (optional) var city: String = "" @@ -74,23 +74,39 @@ extension PostalAddress { } } -// MARK: - JSON Serialization +// MARK: - String Serialization extension PostalAddress { - /// Encodes to pipe-delimited string for storage - nonisolated func toJSON() -> String { - // Use pipe delimiter since it's unlikely to appear in address fields - [street, street2, city, state, postalCode, country].joined(separator: "|") + /// Encodes to JSON string for storage in ContactField.value + nonisolated func encode() -> String { + guard let data = try? JSONEncoder().encode(self), + let jsonString = String(data: data, encoding: .utf8) else { + return "" + } + return jsonString } - /// Decodes from pipe-delimited string - nonisolated static func fromJSON(_ jsonString: String) -> PostalAddress? { - guard !jsonString.isEmpty else { return nil } + /// Decodes from JSON string (with backward compatibility for legacy pipe-delimited format) + nonisolated static func decode(from string: String) -> PostalAddress? { + guard !string.isEmpty else { return nil } - let components = jsonString.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + // Try JSON decoding first (new format) + if let data = string.data(using: .utf8), + let address = try? JSONDecoder().decode(PostalAddress.self, from: data), + address.hasValue { + return address + } + + // Fall back to legacy pipe-delimited format for migration + return decodeLegacy(from: string) + } + + /// Decodes from legacy pipe-delimited format (for backward compatibility) + nonisolated private static func decodeLegacy(from string: String) -> PostalAddress? { + let components = string.split(separator: "|", omittingEmptySubsequences: false).map(String.init) guard components.count == 6 else { return nil } - return PostalAddress( + let address = PostalAddress( street: components[0], street2: components[1], city: components[2], @@ -98,6 +114,8 @@ extension PostalAddress { postalCode: components[4], country: components[5] ) + + return address.hasValue ? address : nil } } diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 67ddafd..2ba5682 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -43,6 +43,12 @@ } } } + }, + "123 Main St" : { + + }, + "12345" : { + }, "About" : { @@ -84,6 +90,9 @@ }, "Address" : { + }, + "Apt, Suite, Unit (optional)" : { + }, "Are you sure you want to delete this card? This action cannot be undone." : { "comment" : "An alert message displayed when the user attempts to delete a card. It confirms the action and warns that it cannot be undone.", @@ -152,6 +161,9 @@ }, "Choose a card in the My Cards tab to start sharing." : { + }, + "City" : { + }, "Company" : { @@ -167,6 +179,12 @@ }, "Contact Fields" : { + }, + "Country" : { + + }, + "Country (optional)" : { + }, "Create multiple business cards" : { "extractionState" : "stale", @@ -546,6 +564,15 @@ }, "Social Media" : { + }, + "State" : { + + }, + "Street" : { + + }, + "Street 2" : { + }, "Suffix (e.g. Jr., III)" : { @@ -706,6 +733,9 @@ }, "Write down a memorable reminder about your contact" : { + }, + "ZIP Code" : { + } }, "version" : "1.1" diff --git a/BusinessCard/Services/WatchSyncService.swift b/BusinessCard/Services/WatchSyncService.swift index 8d71205..30aebd5 100644 --- a/BusinessCard/Services/WatchSyncService.swift +++ b/BusinessCard/Services/WatchSyncService.swift @@ -19,11 +19,14 @@ struct WatchSyncService { let email = card.firstContactField(ofType: "email")?.value ?? "" let phone = card.firstContactField(ofType: "phone")?.value ?? "" let website = card.firstContactField(ofType: "website")?.value ?? "" - let location = card.firstContactField(ofType: "address")?.value ?? "" let linkedIn = card.firstContactField(ofType: "linkedIn")?.value ?? "" let twitter = card.firstContactField(ofType: "twitter")?.value ?? "" let instagram = card.firstContactField(ofType: "instagram")?.value ?? "" + // Format address for display (decode from stored JSON/legacy format) + let addressValue = card.firstContactField(ofType: "address")?.value ?? "" + let location = PostalAddress.decode(from: addressValue)?.singleLineString ?? addressValue + return SyncableCard( id: card.id, displayName: card.displayName, diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift index 714eb79..2dc5d14 100644 --- a/BusinessCard/Views/BusinessCardView.swift +++ b/BusinessCard/Views/BusinessCardView.swift @@ -257,7 +257,7 @@ private struct ContactFieldRowView: View { state: "TX", postalCode: "75024" ) - let addressField = ContactField(typeId: "address", value: address.toJSON(), title: "Work", orderIndex: 3) + let addressField = ContactField(typeId: "address", value: address.encode(), title: "Work", orderIndex: 3) let linkedInField = ContactField(typeId: "linkedIn", value: "linkedin.com/in/mattbruce", title: "", orderIndex: 4) let twitterField = ContactField(typeId: "twitter", value: "twitter.com/mattbruce", title: "", orderIndex: 5) diff --git a/BusinessCard/Views/Components/AddedContactFieldsView.swift b/BusinessCard/Views/Components/AddedContactFieldsView.swift index c16d553..8a33f44 100644 --- a/BusinessCard/Views/Components/AddedContactFieldsView.swift +++ b/BusinessCard/Views/Components/AddedContactFieldsView.swift @@ -28,7 +28,7 @@ struct AddedContactField: Identifiable, Equatable { var shortDisplayValue: String { if fieldType.id == "address" { // For addresses, show single-line format in the list - if let address = PostalAddress.fromJSON(value), address.hasValue { + if let address = PostalAddress.decode(from: value), address.hasValue { return address.singleLineString } } @@ -190,7 +190,7 @@ private struct FieldRow: View { AddedContactField(fieldType: .email, value: "matt@example.com", title: "Work"), AddedContactField(fieldType: .email, value: "personal@example.com", title: "Personal"), AddedContactField(fieldType: .phone, value: "+1 (555) 123-4567", title: "Cell"), - AddedContactField(fieldType: .address, value: address.toJSON(), title: "Work"), + AddedContactField(fieldType: .address, value: address.encode(), title: "Work"), AddedContactField(fieldType: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me") ] }() diff --git a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift index 1405303..25e5034 100644 --- a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift +++ b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift @@ -32,7 +32,7 @@ struct ContactFieldEditorSheet: View { // Parse postal address if this is an address field if fieldType.id == "address" { - if let parsed = PostalAddress.fromJSON(initialValue), parsed.hasValue { + if let parsed = PostalAddress.decode(from: initialValue), parsed.hasValue { _postalAddress = State(initialValue: parsed) } else { _postalAddress = State(initialValue: PostalAddress()) @@ -152,7 +152,7 @@ struct ContactFieldEditorSheet: View { Button("Save") { if isAddressField { // Save postal address as JSON - onSave(postalAddress.toJSON(), title) + onSave(postalAddress.encode(), title) } else { onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title) } @@ -304,7 +304,7 @@ private struct FlowLayout: Layout { ContactFieldEditorSheet( fieldType: .address, - initialValue: existingAddress.toJSON(), + initialValue: existingAddress.encode(), initialTitle: "Work" ) { value, title in print("Saved: \(value), title: \(title)") diff --git a/BusinessCardTests/BusinessCardTests.swift b/BusinessCardTests/BusinessCardTests.swift index 4bf2a6a..ba49e5d 100644 --- a/BusinessCardTests/BusinessCardTests.swift +++ b/BusinessCardTests/BusinessCardTests.swift @@ -19,13 +19,14 @@ struct BusinessCardTests { displayName: "Test User", role: "Developer", company: "Test Corp", - email: "test@example.com", - phone: "+1 555 123 4567", - website: "example.com", - location: "San Francisco, CA", bio: "A passionate developer" ) context.insert(card) + + // Add contact fields using the ContactField relationship + card.addContactField(.email, value: "test@example.com", title: "Work") + card.addContactField(.phone, value: "+1 555 123 4567", title: "Cell") + card.addContactField(.website, value: "example.com", title: "") #expect(card.vCardPayload.contains("BEGIN:VCARD")) #expect(card.vCardPayload.contains("FN:Test User"))