diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift index 3886ea8..ad779fc 100644 --- a/BusinessCard/Models/BusinessCard.swift +++ b/BusinessCard/Models/BusinessCard.swift @@ -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": diff --git a/BusinessCard/Models/Contact.swift b/BusinessCard/Models/Contact.swift index df4ffc0..7363ec7 100644 --- a/BusinessCard/Models/Contact.swift +++ b/BusinessCard/Models/Contact.swift @@ -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[.. 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 + } } diff --git a/BusinessCard/Models/ContactField.swift b/BusinessCard/Models/ContactField.swift index 27f30ef..5f5d799 100644 --- a/BusinessCard/Models/ContactField.swift +++ b/BusinessCard/Models/ContactField.swift @@ -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 diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift index 284e4ae..b3d6120 100644 --- a/BusinessCard/Models/ContactFieldType.swift +++ b/BusinessCard/Models/ContactFieldType.swift @@ -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 diff --git a/BusinessCard/Models/PostalAddress.swift b/BusinessCard/Models/PostalAddress.swift new file mode 100644 index 0000000..007641d --- /dev/null +++ b/BusinessCard/Models/PostalAddress.swift @@ -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)" + } +} diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift index 3ab4d98..714eb79 100644 --- a/BusinessCard/Views/BusinessCardView.swift +++ b/BusinessCard/Views/BusinessCardView.swift @@ -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 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) + + // 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 + }() - // 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: "") - - return BusinessCardView(card: card) + BusinessCardView(card: card) .padding() .background(Color.AppBackground.base) } diff --git a/BusinessCard/Views/Components/AddedContactFieldsView.swift b/BusinessCard/Views/Components/AddedContactFieldsView.swift index 85932e9..c16d553 100644 --- a/BusinessCard/Views/Components/AddedContactFieldsView.swift +++ b/BusinessCard/Views/Components/AddedContactFieldsView.swift @@ -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 diff --git a/BusinessCard/Views/Components/AddressEditorView.swift b/BusinessCard/Views/Components/AddressEditorView.swift new file mode 100644 index 0000000..93aa760 --- /dev/null +++ b/BusinessCard/Views/Components/AddressEditorView.swift @@ -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) + } + } +} diff --git a/BusinessCard/Views/ContactDetailView.swift b/BusinessCard/Views/ContactDetailView.swift index ab3de1a..b9caf6f 100644 --- a/BusinessCard/Views/ContactDetailView.swift +++ b/BusinessCard/Views/ContactDetailView.swift @@ -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 { diff --git a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift index 256895a..1405303 100644 --- a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift +++ b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift @@ -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) - - TextField(fieldType.valuePlaceholder, text: $value) - .keyboardType(fieldType.keyboardType) - .textInputAutocapitalization(fieldType.autocapitalization) - .textContentType(textContentType) - - Divider() + // 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) + + 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") + } +}