Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
954f21616e
commit
24930e6c3e
@ -206,6 +206,7 @@ final class BusinessCard {
|
|||||||
return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ")
|
return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
var vCardPayload: String {
|
var vCardPayload: String {
|
||||||
var lines = [
|
var lines = [
|
||||||
"BEGIN:VCARD",
|
"BEGIN:VCARD",
|
||||||
@ -256,7 +257,14 @@ final class BusinessCard {
|
|||||||
case "website":
|
case "website":
|
||||||
lines.append("URL:\(escapeVCardValue(value))")
|
lines.append("URL:\(escapeVCardValue(value))")
|
||||||
case "address":
|
case "address":
|
||||||
|
// Use structured PostalAddress for proper VCard ADR format
|
||||||
|
if let postalAddress = field.postalAddress {
|
||||||
|
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
|
||||||
|
lines.append(postalAddress.vCardADRLine(type: typeLabel))
|
||||||
|
} else {
|
||||||
|
// Fallback for legacy data
|
||||||
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(value));;;;")
|
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(value));;;;")
|
||||||
|
}
|
||||||
case "linkedIn":
|
case "linkedIn":
|
||||||
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(value))")
|
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(value))")
|
||||||
case "twitter":
|
case "twitter":
|
||||||
|
|||||||
@ -92,6 +92,11 @@ final class Contact {
|
|||||||
sortedContactFields.filter { $0.typeId == "website" || $0.typeId == "customLink" }
|
sortedContactFields.filter { $0.typeId == "website" || $0.typeId == "customLink" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets all address fields
|
||||||
|
var addresses: [ContactField] {
|
||||||
|
fields(ofType: "address")
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this contact has a follow-up reminder set
|
/// Whether this contact has a follow-up reminder set
|
||||||
var hasFollowUp: Bool {
|
var hasFollowUp: Bool {
|
||||||
followUpDate != nil
|
followUpDate != nil
|
||||||
@ -169,8 +174,11 @@ extension Contact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a contact from received vCard data
|
/// Creates a contact from received vCard data
|
||||||
|
@MainActor
|
||||||
static func fromVCard(_ vCardData: String) -> Contact {
|
static func fromVCard(_ vCardData: String) -> Contact {
|
||||||
let contact = Contact(isReceivedCard: true)
|
let contact = Contact(isReceivedCard: true)
|
||||||
|
contact.contactFields = []
|
||||||
|
var fieldIndex = 0
|
||||||
|
|
||||||
// Parse vCard fields
|
// Parse vCard fields
|
||||||
let lines = vCardData.components(separatedBy: "\n")
|
let lines = vCardData.components(separatedBy: "\n")
|
||||||
@ -183,11 +191,47 @@ extension Contact {
|
|||||||
contact.role = String(line.dropFirst(6))
|
contact.role = String(line.dropFirst(6))
|
||||||
} else if line.contains("EMAIL") && line.contains(":") {
|
} else if line.contains("EMAIL") && line.contains(":") {
|
||||||
if let colonIndex = line.firstIndex(of: ":") {
|
if let colonIndex = line.firstIndex(of: ":") {
|
||||||
contact.email = String(line[line.index(after: colonIndex)...])
|
let emailValue = String(line[line.index(after: colonIndex)...])
|
||||||
|
let typeLabel = extractVCardTypeLabel(from: line) ?? "Work"
|
||||||
|
|
||||||
|
let field = ContactField(typeId: "email", value: emailValue, title: typeLabel, orderIndex: fieldIndex)
|
||||||
|
field.contact = contact
|
||||||
|
contact.contactFields?.append(field)
|
||||||
|
fieldIndex += 1
|
||||||
|
|
||||||
|
// Also set legacy field for backward compatibility
|
||||||
|
if contact.email.isEmpty {
|
||||||
|
contact.email = emailValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if line.contains("TEL") && line.contains(":") {
|
} else if line.contains("TEL") && line.contains(":") {
|
||||||
if let colonIndex = line.firstIndex(of: ":") {
|
if let colonIndex = line.firstIndex(of: ":") {
|
||||||
contact.phone = String(line[line.index(after: colonIndex)...])
|
let phoneValue = String(line[line.index(after: colonIndex)...])
|
||||||
|
let typeLabel = extractVCardTypeLabel(from: line) ?? "Cell"
|
||||||
|
|
||||||
|
let field = ContactField(typeId: "phone", value: phoneValue, title: typeLabel, orderIndex: fieldIndex)
|
||||||
|
field.contact = contact
|
||||||
|
contact.contactFields?.append(field)
|
||||||
|
fieldIndex += 1
|
||||||
|
|
||||||
|
// Also set legacy field for backward compatibility
|
||||||
|
if contact.phone.isEmpty {
|
||||||
|
contact.phone = phoneValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if line.contains("ADR") && line.contains(":") {
|
||||||
|
// Parse VCard ADR format: ADR;TYPE=type:;;street;city;state;zip;country
|
||||||
|
if let colonIndex = line.firstIndex(of: ":") {
|
||||||
|
let addressPart = String(line[line.index(after: colonIndex)...])
|
||||||
|
let typeLabel = extractVCardTypeLabel(from: line) ?? "Work"
|
||||||
|
|
||||||
|
let postalAddress = parseVCardADR(addressPart)
|
||||||
|
if postalAddress.hasValue {
|
||||||
|
let field = ContactField(typeId: "address", value: postalAddress.toJSON(), title: typeLabel, orderIndex: fieldIndex)
|
||||||
|
field.contact = contact
|
||||||
|
contact.contactFields?.append(field)
|
||||||
|
fieldIndex += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -196,4 +240,48 @@ extension Contact {
|
|||||||
contact.cardLabel = "Received"
|
contact.cardLabel = "Received"
|
||||||
return contact
|
return contact
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracts TYPE label from VCard line (e.g., "TYPE=WORK" -> "Work")
|
||||||
|
private static func extractVCardTypeLabel(from line: String) -> String? {
|
||||||
|
// Look for TYPE= in the line
|
||||||
|
guard let typeRange = line.range(of: "TYPE=", options: .caseInsensitive) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let afterType = line[typeRange.upperBound...]
|
||||||
|
// Find end of type value (either ; or :)
|
||||||
|
let endIndex = afterType.firstIndex(where: { $0 == ";" || $0 == ":" }) ?? afterType.endIndex
|
||||||
|
let typeValue = String(afterType[..<endIndex])
|
||||||
|
|
||||||
|
// Capitalize first letter only
|
||||||
|
return typeValue.capitalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses VCard ADR format into PostalAddress
|
||||||
|
/// Format: ;;street;city;state;postalCode;country (PO Box and Extended are typically empty)
|
||||||
|
private static func parseVCardADR(_ adrValue: String) -> PostalAddress {
|
||||||
|
let components = adrValue.split(separator: ";", omittingEmptySubsequences: false).map { String($0) }
|
||||||
|
|
||||||
|
var address = PostalAddress()
|
||||||
|
|
||||||
|
// VCard ADR format: PO Box; Extended; Street; City; State; Postal Code; Country
|
||||||
|
// Index: 0 1 2 3 4 5 6
|
||||||
|
if components.count > 2 {
|
||||||
|
address.street = components[2].trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
if components.count > 3 {
|
||||||
|
address.city = components[3].trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
if components.count > 4 {
|
||||||
|
address.state = components[4].trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
if components.count > 5 {
|
||||||
|
address.postalCode = components[5].trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
if components.count > 6 {
|
||||||
|
address.country = components[6].trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
return address
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,28 @@ final class ContactField {
|
|||||||
func buildURL() -> URL? {
|
func buildURL() -> URL? {
|
||||||
fieldType?.buildURL(value: value)
|
fieldType?.buildURL(value: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Postal Address Support
|
||||||
|
|
||||||
|
/// Returns the postal address if this is an address field
|
||||||
|
/// Attempts to parse as JSON first, falls back to legacy string parsing
|
||||||
|
@MainActor
|
||||||
|
var postalAddress: PostalAddress? {
|
||||||
|
guard typeId == "address" else { return nil }
|
||||||
|
|
||||||
|
// Parse as structured PostalAddress
|
||||||
|
if let address = PostalAddress.fromJSON(value), address.hasValue {
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the postal address (stores as JSON)
|
||||||
|
@MainActor
|
||||||
|
func setPostalAddress(_ address: PostalAddress) {
|
||||||
|
self.value = address.toJSON()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Conversion helpers
|
// MARK: - Conversion helpers
|
||||||
|
|||||||
@ -191,7 +191,14 @@ extension ContactFieldType {
|
|||||||
titleSuggestions: [String(localized: "Work"), String(localized: "Home")],
|
titleSuggestions: [String(localized: "Work"), String(localized: "Home")],
|
||||||
keyboardType: .default,
|
keyboardType: .default,
|
||||||
urlBuilder: { value in
|
urlBuilder: { value in
|
||||||
let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
|
// Try to parse as PostalAddress JSON first for proper formatting
|
||||||
|
let searchQuery: String
|
||||||
|
if let address = PostalAddress.fromJSON(value) {
|
||||||
|
searchQuery = address.singleLineString
|
||||||
|
} else {
|
||||||
|
searchQuery = value
|
||||||
|
}
|
||||||
|
let encoded = searchQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? searchQuery
|
||||||
return URL(string: "maps://?q=\(encoded)")
|
return URL(string: "maps://?q=\(encoded)")
|
||||||
},
|
},
|
||||||
displayValueFormatter: formatAddressForDisplay
|
displayValueFormatter: formatAddressForDisplay
|
||||||
@ -655,21 +662,18 @@ extension ContactFieldType {
|
|||||||
// MARK: - Display Value Formatters
|
// MARK: - Display Value Formatters
|
||||||
|
|
||||||
/// Formats an address for multi-line display
|
/// Formats an address for multi-line display
|
||||||
/// Splits on commas and puts each component on a new line
|
/// Tries to parse as structured PostalAddress JSON first, falls back to legacy format
|
||||||
nonisolated private func formatAddressForDisplay(_ value: String) -> String {
|
nonisolated private func formatAddressForDisplay(_ value: String) -> String {
|
||||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return value }
|
guard !trimmed.isEmpty else { return value }
|
||||||
|
|
||||||
// Split by comma, trim each component, and join with newlines
|
// Parse as structured PostalAddress
|
||||||
let components = trimmed
|
if let address = PostalAddress.fromJSON(trimmed), address.hasValue {
|
||||||
.split(separator: ",")
|
return address.formattedString
|
||||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
}
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
|
|
||||||
// If only one component, return as-is
|
// Not a valid address format
|
||||||
guard components.count > 1 else { return trimmed }
|
return trimmed
|
||||||
|
|
||||||
return components.joined(separator: "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - URL Helper Functions
|
// MARK: - URL Helper Functions
|
||||||
|
|||||||
121
BusinessCard/Models/PostalAddress.swift
Normal file
121
BusinessCard/Models/PostalAddress.swift
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
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 {
|
||||||
|
var street: String = "" // Street address line 1 (can include apt/suite)
|
||||||
|
var street2: String = "" // Street address line 2 (optional)
|
||||||
|
var city: String = ""
|
||||||
|
var state: String = ""
|
||||||
|
var postalCode: String = ""
|
||||||
|
var country: String = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
extension PostalAddress {
|
||||||
|
/// Whether all fields are empty
|
||||||
|
nonisolated var isEmpty: Bool {
|
||||||
|
street.isEmpty && street2.isEmpty && city.isEmpty &&
|
||||||
|
state.isEmpty && postalCode.isEmpty && country.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether at least one field has a value
|
||||||
|
nonisolated var hasValue: Bool {
|
||||||
|
!isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats for multi-line display
|
||||||
|
/// Uses a standard format: Street, Street2, City State ZIP, Country
|
||||||
|
nonisolated var formattedString: String {
|
||||||
|
guard hasValue else { return "" }
|
||||||
|
|
||||||
|
var lines: [String] = []
|
||||||
|
|
||||||
|
if !street.isEmpty { lines.append(street) }
|
||||||
|
if !street2.isEmpty { lines.append(street2) }
|
||||||
|
|
||||||
|
// City, State ZIP on one line (US format)
|
||||||
|
var cityStateLine: [String] = []
|
||||||
|
if !city.isEmpty { cityStateLine.append(city) }
|
||||||
|
if !state.isEmpty {
|
||||||
|
cityStateLine.append(state)
|
||||||
|
}
|
||||||
|
if !postalCode.isEmpty {
|
||||||
|
if cityStateLine.isEmpty {
|
||||||
|
cityStateLine.append(postalCode)
|
||||||
|
} else {
|
||||||
|
// Add ZIP to the line
|
||||||
|
let lastIndex = cityStateLine.count - 1
|
||||||
|
cityStateLine[lastIndex] = "\(cityStateLine[lastIndex]) \(postalCode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cityStateLine.isEmpty {
|
||||||
|
lines.append(cityStateLine.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !country.isEmpty { lines.append(country) }
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-line formatted string for compact display
|
||||||
|
nonisolated var singleLineString: String {
|
||||||
|
var components: [String] = []
|
||||||
|
|
||||||
|
if !street.isEmpty { components.append(street) }
|
||||||
|
if !street2.isEmpty { components.append(street2) }
|
||||||
|
if !city.isEmpty { components.append(city) }
|
||||||
|
if !state.isEmpty { components.append(state) }
|
||||||
|
if !postalCode.isEmpty { components.append(postalCode) }
|
||||||
|
if !country.isEmpty { components.append(country) }
|
||||||
|
|
||||||
|
return components.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON 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: "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes from pipe-delimited string
|
||||||
|
nonisolated static func fromJSON(_ jsonString: String) -> PostalAddress? {
|
||||||
|
guard !jsonString.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let components = jsonString.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
|
||||||
|
guard components.count == 6 else { return nil }
|
||||||
|
|
||||||
|
return PostalAddress(
|
||||||
|
street: components[0],
|
||||||
|
street2: components[1],
|
||||||
|
city: components[2],
|
||||||
|
state: components[3],
|
||||||
|
postalCode: components[4],
|
||||||
|
country: components[5]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VCard Support
|
||||||
|
|
||||||
|
extension PostalAddress {
|
||||||
|
/// Generates a VCard ADR line
|
||||||
|
/// Format: ADR;TYPE=type:;;street;city;state;zip;country
|
||||||
|
nonisolated func vCardADRLine(type: String = "WORK") -> String {
|
||||||
|
// VCard ADR format: PO Box; Extended Address; Street; City; State; Postal Code; Country
|
||||||
|
// We use: ;;street;city;state;postalCode;country
|
||||||
|
let streetCombined = street2.isEmpty ? street : "\(street), \(street2)"
|
||||||
|
let escapedStreet = streetCombined.replacing(";", with: "\\;").replacing(",", with: "\\,")
|
||||||
|
let escapedCity = city.replacing(";", with: "\\;").replacing(",", with: "\\,")
|
||||||
|
let escapedState = state.replacing(";", with: "\\;").replacing(",", with: "\\,")
|
||||||
|
let escapedPostal = postalCode.replacing(";", with: "\\;").replacing(",", with: "\\,")
|
||||||
|
let escapedCountry = country.replacing(";", with: "\\;").replacing(",", with: "\\,")
|
||||||
|
|
||||||
|
return "ADR;TYPE=\(type.uppercased()):;;\(escapedStreet);\(escapedCity);\(escapedState);\(escapedPostal);\(escapedCountry)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -194,7 +194,7 @@ private struct ContactFieldRowView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
||||||
// Icon with brand color
|
// Icon with brand color
|
||||||
field.iconImage()
|
field.iconImage()
|
||||||
.font(.body)
|
.font(.body)
|
||||||
@ -204,11 +204,11 @@ private struct ContactFieldRowView: View {
|
|||||||
.clipShape(.circle)
|
.clipShape(.circle)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Value
|
// Value (uses displayValue for formatted output, e.g., multi-line addresses)
|
||||||
Text(field.value)
|
Text(field.displayValue)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
.lineLimit(1)
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
// Title/Label
|
// Title/Label
|
||||||
Text(field.title.isEmpty ? field.displayName : field.title)
|
Text(field.title.isEmpty ? field.displayName : field.title)
|
||||||
@ -235,8 +235,7 @@ private struct ContactFieldRowView: View {
|
|||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
|
@Previewable @State var card: BusinessCard = {
|
||||||
let context = container.mainContext
|
|
||||||
let card = BusinessCard(
|
let card = BusinessCard(
|
||||||
displayName: "Matt Bruce",
|
displayName: "Matt Bruce",
|
||||||
role: "Lead iOS Developer",
|
role: "Lead iOS Developer",
|
||||||
@ -245,17 +244,30 @@ private struct ContactFieldRowView: View {
|
|||||||
layoutStyleRawValue: "stacked",
|
layoutStyleRawValue: "stacked",
|
||||||
headline: "Building the future of mobility"
|
headline: "Building the future of mobility"
|
||||||
)
|
)
|
||||||
context.insert(card)
|
|
||||||
|
|
||||||
// Add contact fields
|
// Add contact fields manually without SwiftData
|
||||||
card.addContactField(.email, value: "matt.bruce@toyota.com", title: "Work")
|
let emailField = ContactField(typeId: "email", value: "matt.bruce@toyota.com", title: "Work", orderIndex: 0)
|
||||||
card.addContactField(.phone, value: "+1 (214) 755-1043", title: "Cell")
|
let phoneField = ContactField(typeId: "phone", value: "+1 (214) 755-1043", title: "Cell", orderIndex: 1)
|
||||||
card.addContactField(.website, value: "toyota.com", title: "")
|
let websiteField = ContactField(typeId: "website", value: "toyota.com", title: "", orderIndex: 2)
|
||||||
card.addContactField(.address, value: "Dallas, TX", title: "Work")
|
|
||||||
card.addContactField(.linkedIn, value: "linkedin.com/in/mattbruce", title: "")
|
|
||||||
card.addContactField(.twitter, value: "twitter.com/mattbruce", title: "")
|
|
||||||
|
|
||||||
return BusinessCardView(card: card)
|
// Create structured address
|
||||||
|
let address = PostalAddress(
|
||||||
|
street: "6565 Headquarters Dr",
|
||||||
|
city: "Plano",
|
||||||
|
state: "TX",
|
||||||
|
postalCode: "75024"
|
||||||
|
)
|
||||||
|
let addressField = ContactField(typeId: "address", value: address.toJSON(), 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)
|
||||||
|
|
||||||
|
card.contactFields = [emailField, phoneField, websiteField, addressField, linkedInField, twitterField]
|
||||||
|
|
||||||
|
return card
|
||||||
|
}()
|
||||||
|
|
||||||
|
BusinessCardView(card: card)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.AppBackground.base)
|
.background(Color.AppBackground.base)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,22 @@ struct AddedContactField: Identifiable, Equatable {
|
|||||||
static func == (lhs: AddedContactField, rhs: AddedContactField) -> Bool {
|
static func == (lhs: AddedContactField, rhs: AddedContactField) -> Bool {
|
||||||
lhs.id == rhs.id && lhs.value == rhs.value && lhs.title == rhs.title
|
lhs.id == rhs.id && lhs.value == rhs.value && lhs.title == rhs.title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the display value for this field (formatted for addresses, raw for others)
|
||||||
|
var displayValue: String {
|
||||||
|
fieldType.formattedDisplayValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a short display value suitable for single-line display in lists
|
||||||
|
var shortDisplayValue: String {
|
||||||
|
if fieldType.id == "address" {
|
||||||
|
// For addresses, show single-line format in the list
|
||||||
|
if let address = PostalAddress.fromJSON(value), address.hasValue {
|
||||||
|
return address.singleLineString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
|
/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
|
||||||
@ -91,7 +107,7 @@ private struct FieldRowPreview: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
Text(field.value.isEmpty ? field.fieldType.displayName : field.value)
|
Text(field.value.isEmpty ? field.fieldType.displayName : field.shortDisplayValue)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -138,7 +154,7 @@ private struct FieldRow: View {
|
|||||||
// Content - tap to edit
|
// Content - tap to edit
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
Text(field.value.isEmpty ? field.fieldType.valuePlaceholder : field.value)
|
Text(field.value.isEmpty ? field.fieldType.valuePlaceholder : field.shortDisplayValue)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(field.value.isEmpty ? Color.Text.secondary : Color.Text.primary)
|
.foregroundStyle(field.value.isEmpty ? Color.Text.secondary : Color.Text.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -168,12 +184,16 @@ private struct FieldRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@Previewable @State var fields: [AddedContactField] = [
|
@Previewable @State var fields: [AddedContactField] = {
|
||||||
|
let address = PostalAddress(street: "6565 Headquarters Dr", city: "Plano", state: "TX", postalCode: "75024")
|
||||||
|
return [
|
||||||
AddedContactField(fieldType: .email, value: "matt@example.com", title: "Work"),
|
AddedContactField(fieldType: .email, value: "matt@example.com", title: "Work"),
|
||||||
AddedContactField(fieldType: .email, value: "personal@example.com", title: "Personal"),
|
AddedContactField(fieldType: .email, value: "personal@example.com", title: "Personal"),
|
||||||
AddedContactField(fieldType: .phone, value: "+1 (555) 123-4567", title: "Cell"),
|
AddedContactField(fieldType: .phone, value: "+1 (555) 123-4567", title: "Cell"),
|
||||||
|
AddedContactField(fieldType: .address, value: address.toJSON(), title: "Work"),
|
||||||
AddedContactField(fieldType: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me")
|
AddedContactField(fieldType: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me")
|
||||||
]
|
]
|
||||||
|
}()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
AddedContactFieldsView(fields: $fields) { field in
|
AddedContactFieldsView(fields: $fields) { field in
|
||||||
|
|||||||
104
BusinessCard/Views/Components/AddressEditorView.swift
Normal file
104
BusinessCard/Views/Components/AddressEditorView.swift
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// A form view for editing a structured postal address
|
||||||
|
struct AddressEditorView: View {
|
||||||
|
@Binding var address: PostalAddress
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
AddressTextField(
|
||||||
|
label: String(localized: "Street"),
|
||||||
|
placeholder: String(localized: "123 Main St"),
|
||||||
|
text: $address.street,
|
||||||
|
textContentType: .streetAddressLine1
|
||||||
|
)
|
||||||
|
|
||||||
|
AddressTextField(
|
||||||
|
label: String(localized: "Street 2"),
|
||||||
|
placeholder: String(localized: "Apt, Suite, Unit (optional)"),
|
||||||
|
text: $address.street2,
|
||||||
|
textContentType: .streetAddressLine2
|
||||||
|
)
|
||||||
|
|
||||||
|
AddressTextField(
|
||||||
|
label: String(localized: "City"),
|
||||||
|
placeholder: String(localized: "City"),
|
||||||
|
text: $address.city,
|
||||||
|
textContentType: .addressCity
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
AddressTextField(
|
||||||
|
label: String(localized: "State"),
|
||||||
|
placeholder: String(localized: "State"),
|
||||||
|
text: $address.state,
|
||||||
|
textContentType: .addressState
|
||||||
|
)
|
||||||
|
|
||||||
|
AddressTextField(
|
||||||
|
label: String(localized: "ZIP Code"),
|
||||||
|
placeholder: String(localized: "12345"),
|
||||||
|
text: $address.postalCode,
|
||||||
|
textContentType: .postalCode,
|
||||||
|
keyboardType: .numbersAndPunctuation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressTextField(
|
||||||
|
label: String(localized: "Country"),
|
||||||
|
placeholder: String(localized: "Country (optional)"),
|
||||||
|
text: $address.country,
|
||||||
|
textContentType: .countryName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Address Text Field
|
||||||
|
|
||||||
|
private struct AddressTextField: View {
|
||||||
|
let label: String
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
var textContentType: UITextContentType?
|
||||||
|
var keyboardType: UIKeyboardType = .default
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.textContentType(textContentType)
|
||||||
|
.keyboardType(keyboardType)
|
||||||
|
.textInputAutocapitalization(.words)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var address = PostalAddress(
|
||||||
|
street: "6565 Headquarters Dr",
|
||||||
|
city: "Plano",
|
||||||
|
state: "TX",
|
||||||
|
postalCode: "75024"
|
||||||
|
)
|
||||||
|
|
||||||
|
Form {
|
||||||
|
Section("Address") {
|
||||||
|
AddressEditorView(address: $address)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Preview") {
|
||||||
|
Text(address.formattedString)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -380,61 +380,65 @@ private struct ContactInfoCard: View {
|
|||||||
let contact: Contact
|
let contact: Contact
|
||||||
let openURL: (String) -> Void
|
let openURL: (String) -> Void
|
||||||
|
|
||||||
private var allContactItems: [(icon: String, value: String, label: String, urlScheme: String)] {
|
private var allContactFields: [ContactField] {
|
||||||
var items: [(icon: String, value: String, label: String, urlScheme: String)] = []
|
var fields: [ContactField] = []
|
||||||
|
|
||||||
// Add from new contact fields (phones)
|
// Add phones
|
||||||
for field in contact.phoneNumbers {
|
fields.append(contentsOf: contact.phoneNumbers)
|
||||||
items.append((icon: "phone.fill", value: field.value, label: field.title.isEmpty ? "Phone" : field.title, urlScheme: "tel:"))
|
|
||||||
|
// Add emails
|
||||||
|
fields.append(contentsOf: contact.emailAddresses)
|
||||||
|
|
||||||
|
// Add addresses
|
||||||
|
fields.append(contentsOf: contact.addresses)
|
||||||
|
|
||||||
|
// Add links
|
||||||
|
fields.append(contentsOf: contact.links)
|
||||||
|
|
||||||
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add from new contact fields (emails)
|
private var hasLegacyFields: Bool {
|
||||||
for field in contact.emailAddresses {
|
!contact.phone.isEmpty || !contact.email.isEmpty
|
||||||
items.append((icon: "envelope.fill", value: field.value, label: field.title.isEmpty ? "Email" : field.title, urlScheme: "mailto:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add from new contact fields (links)
|
|
||||||
for field in contact.links {
|
|
||||||
let url = field.value.hasPrefix("http") ? field.value : "https://\(field.value)"
|
|
||||||
items.append((icon: "link", value: field.value, label: field.title.isEmpty ? "Link" : field.title, urlScheme: url.hasPrefix("http") ? "" : "https://"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy fallback: if no new fields, show legacy email/phone
|
|
||||||
if items.isEmpty {
|
|
||||||
if !contact.phone.isEmpty {
|
|
||||||
items.append((icon: "phone.fill", value: contact.phone, label: "Cell", urlScheme: "tel:"))
|
|
||||||
}
|
|
||||||
if !contact.email.isEmpty {
|
|
||||||
items.append((icon: "envelope.fill", value: contact.email, label: "Email", urlScheme: "mailto:"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if !allContactItems.isEmpty {
|
if !allContactFields.isEmpty || hasLegacyFields {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(allContactItems.indices, id: \.self) { index in
|
// New contact fields
|
||||||
let item = allContactItems[index]
|
ForEach(allContactFields.indices, id: \.self) { index in
|
||||||
ContactInfoRow(
|
let field = allContactFields[index]
|
||||||
icon: item.icon,
|
ContactFieldInfoRow(field: field, openURL: openURL)
|
||||||
value: item.value,
|
|
||||||
label: item.label,
|
|
||||||
action: {
|
|
||||||
if item.urlScheme.isEmpty {
|
|
||||||
openURL(item.value)
|
|
||||||
} else {
|
|
||||||
openURL("\(item.urlScheme)\(item.value)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if index < allContactItems.count - 1 {
|
if index < allContactFields.count - 1 || hasLegacyFields {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy fallback: show legacy email/phone if no new fields of that type
|
||||||
|
if contact.phoneNumbers.isEmpty && !contact.phone.isEmpty {
|
||||||
|
ContactInfoRow(
|
||||||
|
icon: "phone.fill",
|
||||||
|
value: contact.phone,
|
||||||
|
label: "Cell",
|
||||||
|
action: { openURL("tel:\(contact.phone)") }
|
||||||
|
)
|
||||||
|
|
||||||
|
if !contact.email.isEmpty && contact.emailAddresses.isEmpty {
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if contact.emailAddresses.isEmpty && !contact.email.isEmpty {
|
||||||
|
ContactInfoRow(
|
||||||
|
icon: "envelope.fill",
|
||||||
|
value: contact.email,
|
||||||
|
label: "Email",
|
||||||
|
action: { openURL("mailto:\(contact.email)") }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(Color.AppBackground.card)
|
.background(Color.AppBackground.card)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
@ -442,6 +446,47 @@ private struct ContactInfoCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Contact Field Info Row
|
||||||
|
|
||||||
|
private struct ContactFieldInfoRow: View {
|
||||||
|
let field: ContactField
|
||||||
|
let openURL: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
if let url = field.buildURL() {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
||||||
|
// Icon circle
|
||||||
|
field.iconImage()
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
||||||
|
.background(Color.CardPalette.coral)
|
||||||
|
.clipShape(.circle)
|
||||||
|
|
||||||
|
// Text
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(field.displayValue)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
Text(field.title.isEmpty ? field.displayName : field.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Contact Info Row
|
// MARK: - Contact Info Row
|
||||||
|
|
||||||
private struct ContactInfoRow: View {
|
private struct ContactInfoRow: View {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ struct ContactFieldEditorSheet: View {
|
|||||||
|
|
||||||
@State private var value: String
|
@State private var value: String
|
||||||
@State private var title: String
|
@State private var title: String
|
||||||
|
@State private var postalAddress: PostalAddress
|
||||||
|
|
||||||
init(
|
init(
|
||||||
fieldType: ContactFieldType,
|
fieldType: ContactFieldType,
|
||||||
@ -28,10 +29,28 @@ struct ContactFieldEditorSheet: View {
|
|||||||
self.onDelete = onDelete
|
self.onDelete = onDelete
|
||||||
_value = State(initialValue: initialValue)
|
_value = State(initialValue: initialValue)
|
||||||
_title = State(initialValue: initialTitle)
|
_title = State(initialValue: initialTitle)
|
||||||
|
|
||||||
|
// Parse postal address if this is an address field
|
||||||
|
if fieldType.id == "address" {
|
||||||
|
if let parsed = PostalAddress.fromJSON(initialValue), parsed.hasValue {
|
||||||
|
_postalAddress = State(initialValue: parsed)
|
||||||
|
} else {
|
||||||
|
_postalAddress = State(initialValue: PostalAddress())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_postalAddress = State(initialValue: PostalAddress())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isAddressField: Bool {
|
||||||
|
fieldType.id == "address"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isValid: Bool {
|
private var isValid: Bool {
|
||||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
if isAddressField {
|
||||||
|
return postalAddress.hasValue
|
||||||
|
}
|
||||||
|
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isEditing: Bool {
|
private var isEditing: Bool {
|
||||||
@ -48,7 +67,10 @@ struct ContactFieldEditorSheet: View {
|
|||||||
|
|
||||||
// Form content
|
// Form content
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
|
||||||
// Value field
|
// Value field - different for address vs other fields
|
||||||
|
if isAddressField {
|
||||||
|
AddressEditorView(address: $postalAddress)
|
||||||
|
} else {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
Text(fieldType.valueLabel)
|
Text(fieldType.valueLabel)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@ -61,6 +83,7 @@ struct ContactFieldEditorSheet: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Title field
|
// Title field
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
@ -127,7 +150,12 @@ struct ContactFieldEditorSheet: View {
|
|||||||
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
|
if isAddressField {
|
||||||
|
// Save postal address as JSON
|
||||||
|
onSave(postalAddress.toJSON(), title)
|
||||||
|
} else {
|
||||||
onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title)
|
onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title)
|
||||||
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.disabled(!isValid)
|
.disabled(!isValid)
|
||||||
@ -259,3 +287,28 @@ private struct FlowLayout: Layout {
|
|||||||
print("Deleted")
|
print("Deleted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("Add Address") {
|
||||||
|
ContactFieldEditorSheet(fieldType: .address) { value, title in
|
||||||
|
print("Saved address: \(value), title: \(title)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Edit Address") {
|
||||||
|
let existingAddress = PostalAddress(
|
||||||
|
street: "6565 Headquarters Dr",
|
||||||
|
city: "Plano",
|
||||||
|
state: "TX",
|
||||||
|
postalCode: "75024"
|
||||||
|
)
|
||||||
|
|
||||||
|
ContactFieldEditorSheet(
|
||||||
|
fieldType: .address,
|
||||||
|
initialValue: existingAddress.toJSON(),
|
||||||
|
initialTitle: "Work"
|
||||||
|
) { value, title in
|
||||||
|
print("Saved: \(value), title: \(title)")
|
||||||
|
} onDelete: {
|
||||||
|
print("Deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user