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

This commit is contained in:
Matt Bruce 2026-01-09 11:38:39 -06:00
parent 24930e6c3e
commit 1080590fde
10 changed files with 80 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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