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: " ")
|
||||
}
|
||||
|
||||
@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":
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
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 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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user