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

This commit is contained in:
Matt Bruce 2026-01-09 11:02:26 -06:00
parent 954f21616e
commit 24930e6c3e
10 changed files with 578 additions and 101 deletions

View File

@ -206,6 +206,7 @@ final class BusinessCard {
return parts.isEmpty ? computedDisplayName : parts.joined(separator: " ")
}
@MainActor
var vCardPayload: String {
var lines = [
"BEGIN:VCARD",
@ -256,7 +257,14 @@ final class BusinessCard {
case "website":
lines.append("URL:\(escapeVCardValue(value))")
case "address":
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(value));;;;")
// 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));;;;")
}
case "linkedIn":
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(value))")
case "twitter":

View File

@ -92,6 +92,11 @@ final class Contact {
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
var hasFollowUp: Bool {
followUpDate != nil
@ -169,8 +174,11 @@ extension Contact {
}
/// Creates a contact from received vCard data
@MainActor
static func fromVCard(_ vCardData: String) -> Contact {
let contact = Contact(isReceivedCard: true)
contact.contactFields = []
var fieldIndex = 0
// Parse vCard fields
let lines = vCardData.components(separatedBy: "\n")
@ -183,11 +191,47 @@ extension Contact {
contact.role = String(line.dropFirst(6))
} else if line.contains("EMAIL") && line.contains(":") {
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(":") {
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"
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
}
}

View File

@ -88,6 +88,28 @@ final class ContactField {
func buildURL() -> URL? {
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

View File

@ -191,7 +191,14 @@ extension ContactFieldType {
titleSuggestions: [String(localized: "Work"), String(localized: "Home")],
keyboardType: .default,
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)")
},
displayValueFormatter: formatAddressForDisplay
@ -655,21 +662,18 @@ extension ContactFieldType {
// MARK: - Display Value Formatters
/// 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 {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return value }
// Split by comma, trim each component, and join with newlines
let components = trimmed
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
// Parse as structured PostalAddress
if let address = PostalAddress.fromJSON(trimmed), address.hasValue {
return address.formattedString
}
// If only one component, return as-is
guard components.count > 1 else { return trimmed }
return components.joined(separator: "\n")
// Not a valid address format
return trimmed
}
// MARK: - URL Helper Functions

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

View File

@ -194,7 +194,7 @@ private struct ContactFieldRowView: View {
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
HStack(alignment: .top, spacing: Design.Spacing.medium) {
// Icon with brand color
field.iconImage()
.font(.body)
@ -204,11 +204,11 @@ private struct ContactFieldRowView: View {
.clipShape(.circle)
VStack(alignment: .leading, spacing: 0) {
// Value
Text(field.value)
// Value (uses displayValue for formatted output, e.g., multi-line addresses)
Text(field.displayValue)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
.lineLimit(1)
.multilineTextAlignment(.leading)
// Title/Label
Text(field.title.isEmpty ? field.displayName : field.title)
@ -235,27 +235,39 @@ private struct ContactFieldRowView: View {
// MARK: - Preview
#Preview {
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
let context = container.mainContext
let card = BusinessCard(
displayName: "Matt Bruce",
role: "Lead iOS Developer",
company: "Toyota",
themeName: "Coral",
layoutStyleRawValue: "stacked",
headline: "Building the future of mobility"
)
context.insert(card)
@Previewable @State var card: BusinessCard = {
let card = BusinessCard(
displayName: "Matt Bruce",
role: "Lead iOS Developer",
company: "Toyota",
themeName: "Coral",
layoutStyleRawValue: "stacked",
headline: "Building the future of mobility"
)
// Add contact fields
card.addContactField(.email, value: "matt.bruce@toyota.com", title: "Work")
card.addContactField(.phone, value: "+1 (214) 755-1043", title: "Cell")
card.addContactField(.website, value: "toyota.com", title: "")
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: "")
// Add contact fields manually without SwiftData
let emailField = ContactField(typeId: "email", value: "matt.bruce@toyota.com", title: "Work", orderIndex: 0)
let phoneField = ContactField(typeId: "phone", value: "+1 (214) 755-1043", title: "Cell", orderIndex: 1)
let websiteField = ContactField(typeId: "website", value: "toyota.com", title: "", orderIndex: 2)
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()
.background(Color.AppBackground.base)
}

View File

@ -18,6 +18,22 @@ struct AddedContactField: Identifiable, Equatable {
static func == (lhs: AddedContactField, rhs: AddedContactField) -> Bool {
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
@ -91,7 +107,7 @@ private struct FieldRowPreview: View {
)
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)
.foregroundStyle(Color.Text.primary)
.lineLimit(1)
@ -138,7 +154,7 @@ private struct FieldRow: View {
// Content - tap to edit
Button(action: onTap) {
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)
.foregroundStyle(field.value.isEmpty ? Color.Text.secondary : Color.Text.primary)
.lineLimit(1)
@ -168,12 +184,16 @@ private struct FieldRow: View {
}
#Preview {
@Previewable @State var fields: [AddedContactField] = [
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: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me")
]
@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: "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: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me")
]
}()
ScrollView {
AddedContactFieldsView(fields: $fields) { field in

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

View File

@ -380,61 +380,65 @@ private struct ContactInfoCard: View {
let contact: Contact
let openURL: (String) -> Void
private var allContactItems: [(icon: String, value: String, label: String, urlScheme: String)] {
var items: [(icon: String, value: String, label: String, urlScheme: String)] = []
private var allContactFields: [ContactField] {
var fields: [ContactField] = []
// Add from new contact fields (phones)
for field in contact.phoneNumbers {
items.append((icon: "phone.fill", value: field.value, label: field.title.isEmpty ? "Phone" : field.title, urlScheme: "tel:"))
}
// Add phones
fields.append(contentsOf: contact.phoneNumbers)
// Add from new contact fields (emails)
for field in contact.emailAddresses {
items.append((icon: "envelope.fill", value: field.value, label: field.title.isEmpty ? "Email" : field.title, urlScheme: "mailto:"))
}
// Add emails
fields.append(contentsOf: contact.emailAddresses)
// 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://"))
}
// Add addresses
fields.append(contentsOf: contact.addresses)
// 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:"))
}
}
// Add links
fields.append(contentsOf: contact.links)
return items
return fields
}
private var hasLegacyFields: Bool {
!contact.phone.isEmpty || !contact.email.isEmpty
}
var body: some View {
if !allContactItems.isEmpty {
if !allContactFields.isEmpty || hasLegacyFields {
VStack(spacing: 0) {
ForEach(allContactItems.indices, id: \.self) { index in
let item = allContactItems[index]
ContactInfoRow(
icon: item.icon,
value: item.value,
label: item.label,
action: {
if item.urlScheme.isEmpty {
openURL(item.value)
} else {
openURL("\(item.urlScheme)\(item.value)")
}
}
)
// New contact fields
ForEach(allContactFields.indices, id: \.self) { index in
let field = allContactFields[index]
ContactFieldInfoRow(field: field, openURL: openURL)
if index < allContactItems.count - 1 {
if index < allContactFields.count - 1 || hasLegacyFields {
Divider()
.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)
.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
private struct ContactInfoRow: View {

View File

@ -13,6 +13,7 @@ struct ContactFieldEditorSheet: View {
@State private var value: String
@State private var title: String
@State private var postalAddress: PostalAddress
init(
fieldType: ContactFieldType,
@ -28,10 +29,28 @@ struct ContactFieldEditorSheet: View {
self.onDelete = onDelete
_value = State(initialValue: initialValue)
_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 {
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if isAddressField {
return postalAddress.hasValue
}
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var isEditing: Bool {
@ -48,18 +67,22 @@ struct ContactFieldEditorSheet: View {
// Form content
VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
// Value field
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(fieldType.valueLabel)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
// Value field - different for address vs other fields
if isAddressField {
AddressEditorView(address: $postalAddress)
} else {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(fieldType.valueLabel)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
TextField(fieldType.valuePlaceholder, text: $value)
.keyboardType(fieldType.keyboardType)
.textInputAutocapitalization(fieldType.autocapitalization)
.textContentType(textContentType)
TextField(fieldType.valuePlaceholder, text: $value)
.keyboardType(fieldType.keyboardType)
.textInputAutocapitalization(fieldType.autocapitalization)
.textContentType(textContentType)
Divider()
Divider()
}
}
// Title field
@ -127,7 +150,12 @@ struct ContactFieldEditorSheet: View {
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title)
if isAddressField {
// Save postal address as JSON
onSave(postalAddress.toJSON(), title)
} else {
onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title)
}
dismiss()
}
.disabled(!isValid)
@ -259,3 +287,28 @@ private struct FlowLayout: Layout {
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")
}
}