Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
540bd095dd
commit
3e9436791a
@ -9,9 +9,10 @@ struct BusinessCardApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
let schema = Schema([BusinessCard.self, Contact.self, ContactField.self])
|
let schema = Schema([BusinessCard.self, Contact.self, ContactField.self])
|
||||||
let cloudKitDatabase: ModelConfiguration.CloudKitDatabase = .private(
|
let cloudKitDatabase: ModelConfiguration.CloudKitDatabase =
|
||||||
AppIdentifiers.cloudKitContainerIdentifier
|
AppIdentifiers.isCloudKitSyncEnabled
|
||||||
)
|
? .private(AppIdentifiers.cloudKitContainerIdentifier)
|
||||||
|
: .none
|
||||||
|
|
||||||
// Register app theme for Bedrock semantic text/surface colors.
|
// Register app theme for Bedrock semantic text/surface colors.
|
||||||
Theme.register(
|
Theme.register(
|
||||||
@ -22,39 +23,55 @@ struct BusinessCardApp: App {
|
|||||||
)
|
)
|
||||||
Theme.register(border: AppBorder.self)
|
Theme.register(border: AppBorder.self)
|
||||||
|
|
||||||
// Primary strategy: App Group-backed persistent store with CloudKit sync.
|
// Primary strategy: App Group-backed persistent store.
|
||||||
var container: ModelContainer?
|
var container: ModelContainer?
|
||||||
|
|
||||||
if let appGroupURL = FileManager.default.containerURL(
|
func makeContainer(storeURL: URL, cloudKitDatabase: ModelConfiguration.CloudKitDatabase) -> ModelContainer? {
|
||||||
forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier
|
|
||||||
) {
|
|
||||||
let storeURL = appGroupURL.appending(path: "BusinessCard.store")
|
|
||||||
let config = ModelConfiguration(
|
let config = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
url: storeURL,
|
url: storeURL,
|
||||||
cloudKitDatabase: cloudKitDatabase
|
cloudKitDatabase: cloudKitDatabase
|
||||||
)
|
)
|
||||||
|
return try? ModelContainer(for: schema, configurations: [config])
|
||||||
do {
|
}
|
||||||
container = try ModelContainer(for: schema, configurations: [config])
|
|
||||||
} catch {
|
if let appGroupURL = FileManager.default.containerURL(
|
||||||
Design.debugLog("Failed to create container with App Group: \(error)")
|
forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier
|
||||||
|
) {
|
||||||
|
let storeURL = appGroupURL.appending(path: "BusinessCard.store")
|
||||||
|
container = makeContainer(storeURL: storeURL, cloudKitDatabase: cloudKitDatabase)
|
||||||
|
if container == nil {
|
||||||
|
Design.debugLog("Failed to create container in App Group.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Default location (Application Support)
|
// Fallback: Default location (Application Support)
|
||||||
if container == nil {
|
if container == nil {
|
||||||
let storeURL = URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
|
let storeURL = URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
|
||||||
let config = ModelConfiguration(
|
container = makeContainer(storeURL: storeURL, cloudKitDatabase: cloudKitDatabase)
|
||||||
schema: schema,
|
if container == nil {
|
||||||
url: storeURL,
|
Design.debugLog("Failed to create container in Application Support.")
|
||||||
cloudKitDatabase: cloudKitDatabase
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// If CloudKit mode fails, force local-on-disk fallback before in-memory.
|
||||||
|
if container == nil && AppIdentifiers.isCloudKitSyncEnabled {
|
||||||
|
if let appGroupURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier
|
||||||
|
) {
|
||||||
|
let storeURL = appGroupURL.appending(path: "BusinessCard.store")
|
||||||
|
container = makeContainer(storeURL: storeURL, cloudKitDatabase: .none)
|
||||||
|
if container == nil {
|
||||||
|
Design.debugLog("Failed to create local-only container in App Group.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
if container == nil {
|
||||||
container = try ModelContainer(for: schema, configurations: [config])
|
let storeURL = URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
|
||||||
} catch {
|
container = makeContainer(storeURL: storeURL, cloudKitDatabase: .none)
|
||||||
Design.debugLog("Failed to create container in Application Support: \(error)")
|
if container == nil {
|
||||||
|
Design.debugLog("Failed to create local-only container in Application Support.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,13 @@ enum AppIdentifiers {
|
|||||||
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
|
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
|
||||||
?? "iCloud.com.mbrucedogs.BusinessCard"
|
?? "iCloud.com.mbrucedogs.BusinessCard"
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Whether CloudKit-backed SwiftData sync should be enabled.
|
||||||
|
/// Defaults to false so local persistence works even when CloudKit model constraints are unmet.
|
||||||
|
static let isCloudKitSyncEnabled: Bool = {
|
||||||
|
let rawValue = (Bundle.main.object(forInfoDictionaryKey: "CloudKitSyncEnabled") as? String) ?? "NO"
|
||||||
|
return NSString(string: rawValue).boolValue
|
||||||
|
}()
|
||||||
|
|
||||||
/// App Clip domain for sharing URLs.
|
/// App Clip domain for sharing URLs.
|
||||||
static let appClipDomain: String = {
|
static let appClipDomain: String = {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests
|
|||||||
// Entitlement identifiers
|
// Entitlement identifiers
|
||||||
APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME)
|
APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME)
|
||||||
CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)
|
CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)
|
||||||
|
CLOUDKIT_SYNC_ENABLED = NO
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// APP CLIP CONFIGURATION
|
// APP CLIP CONFIGURATION
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||||
<key>CloudKitContainerIdentifier</key>
|
<key>CloudKitContainerIdentifier</key>
|
||||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||||
|
<key>CloudKitSyncEnabled</key>
|
||||||
|
<string>$(CLOUDKIT_SYNC_ENABLED)</string>
|
||||||
<key>AppClipDomain</key>
|
<key>AppClipDomain</key>
|
||||||
<string>$(APPCLIP_DOMAIN)</string>
|
<string>$(APPCLIP_DOMAIN)</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
|
|||||||
@ -151,7 +151,8 @@ extension ContactFieldType {
|
|||||||
urlBuilder: { value in
|
urlBuilder: { value in
|
||||||
let digits = value.filter { $0.isNumber || $0 == "+" }
|
let digits = value.filter { $0.isNumber || $0 == "+" }
|
||||||
return URL(string: "tel:\(digits)")
|
return URL(string: "tel:\(digits)")
|
||||||
}
|
},
|
||||||
|
displayValueFormatter: formatPhoneForDisplay
|
||||||
)
|
)
|
||||||
|
|
||||||
static let email = ContactFieldType(
|
static let email = ContactFieldType(
|
||||||
@ -676,6 +677,12 @@ nonisolated private func formatAddressForDisplay(_ value: String) -> String {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats a phone number for display with lightweight US-first grouping.
|
||||||
|
/// Falls back to a spaced international style for non-US lengths.
|
||||||
|
nonisolated private func formatPhoneForDisplay(_ value: String) -> String {
|
||||||
|
PhoneNumberText.formatted(value)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - URL Helper Functions
|
// MARK: - URL Helper Functions
|
||||||
|
|
||||||
nonisolated private func buildWebURL(_ value: String) -> URL? {
|
nonisolated private func buildWebURL(_ value: String) -> URL? {
|
||||||
@ -706,3 +713,134 @@ nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL
|
|||||||
let username = trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
|
let username = trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
|
||||||
return URL(string: "https://\(webBase)/\(username)")
|
return URL(string: "https://\(webBase)/\(username)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Phone Text Utilities
|
||||||
|
|
||||||
|
enum PhoneNumberText {
|
||||||
|
nonisolated static func normalizedForStorage(_ value: String) -> String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let hasLeadingPlus = trimmed.hasPrefix("+")
|
||||||
|
let digits = trimmed.filter(\.isNumber)
|
||||||
|
|
||||||
|
guard !digits.isEmpty else { return "" }
|
||||||
|
if hasLeadingPlus {
|
||||||
|
return "+\(digits)"
|
||||||
|
}
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func isValid(_ value: String) -> Bool {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
|
||||||
|
let plusCount = trimmed.filter { $0 == "+" }.count
|
||||||
|
if plusCount > 1 { return false }
|
||||||
|
if plusCount == 1 && !trimmed.hasPrefix("+") { return false }
|
||||||
|
|
||||||
|
let digits = trimmed.filter(\.isNumber)
|
||||||
|
return (7...15).contains(digits.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func formatted(_ raw: String) -> String {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "" }
|
||||||
|
|
||||||
|
let hasLeadingPlus = trimmed.hasPrefix("+")
|
||||||
|
let digits = trimmed.filter(\.isNumber)
|
||||||
|
guard !digits.isEmpty else { return hasLeadingPlus ? "+" : "" }
|
||||||
|
|
||||||
|
if hasLeadingPlus {
|
||||||
|
if digits.count == 11, digits.first == "1" {
|
||||||
|
return "+1 \(formatUSDigits(String(digits.dropFirst())))"
|
||||||
|
}
|
||||||
|
return "+" + formatInternationalDigits(digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
if digits.count == 11, digits.first == "1" {
|
||||||
|
return "1 \(formatUSDigits(String(digits.dropFirst())))"
|
||||||
|
}
|
||||||
|
if digits.count <= 10 {
|
||||||
|
return formatUSDigits(digits)
|
||||||
|
}
|
||||||
|
return formatInternationalDigits(digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func formatUSDigits(_ digits: String) -> String {
|
||||||
|
if digits.isEmpty { return "" }
|
||||||
|
|
||||||
|
if digits.count <= 3 {
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
|
let area = String(digits.prefix(3))
|
||||||
|
let remaining = String(digits.dropFirst(3))
|
||||||
|
|
||||||
|
if remaining.count <= 3 {
|
||||||
|
return "(\(area)) \(remaining)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = String(remaining.prefix(3))
|
||||||
|
let line = String(remaining.dropFirst(3).prefix(4))
|
||||||
|
return "(\(area)) \(prefix)-\(line)"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func formatInternationalDigits(_ digits: String) -> String {
|
||||||
|
guard !digits.isEmpty else { return "" }
|
||||||
|
|
||||||
|
if digits.count <= 3 {
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups: [String] = []
|
||||||
|
var index = digits.startIndex
|
||||||
|
|
||||||
|
while index < digits.endIndex {
|
||||||
|
let next = digits.index(index, offsetBy: 3, limitedBy: digits.endIndex) ?? digits.endIndex
|
||||||
|
groups.append(String(digits[index..<next]))
|
||||||
|
index = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EmailText {
|
||||||
|
nonisolated static func normalizedForStorage(_ value: String) -> String {
|
||||||
|
value
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func isValid(_ value: String) -> Bool {
|
||||||
|
let trimmed = normalizedForStorage(value)
|
||||||
|
guard !trimmed.isEmpty, !trimmed.contains(" ") else { return false }
|
||||||
|
|
||||||
|
let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
|
||||||
|
guard parts.count == 2 else { return false }
|
||||||
|
|
||||||
|
let local = String(parts[0])
|
||||||
|
let domain = String(parts[1])
|
||||||
|
|
||||||
|
guard !local.isEmpty, !domain.isEmpty else { return false }
|
||||||
|
guard domain.contains("."), !domain.hasPrefix("."), !domain.hasSuffix(".") else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WebLinkText {
|
||||||
|
nonisolated static func normalizedForStorage(_ value: String) -> String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "" }
|
||||||
|
if let url = buildWebURL(trimmed) {
|
||||||
|
return url.absoluteString
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func isValid(_ value: String) -> Bool {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, !trimmed.contains(" ") else { return false }
|
||||||
|
guard let url = buildWebURL(trimmed) else { return false }
|
||||||
|
return url.host != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -149,7 +149,8 @@ final class ContactsStore: ContactTracking {
|
|||||||
do {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
} catch {
|
} catch {
|
||||||
// Handle error silently for now
|
modelContext.rollback()
|
||||||
|
print("ContactsStore save failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,8 +36,30 @@ struct AddContactSheet: View {
|
|||||||
@State private var notes = ""
|
@State private var notes = ""
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
!firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
let hasName = !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
||||||
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
return hasName && !hasInvalidPhoneEntry && !hasInvalidEmailEntry && !hasInvalidLinkEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasInvalidPhoneEntry: Bool {
|
||||||
|
phoneEntries.contains { entry in
|
||||||
|
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return !trimmed.isEmpty && !PhoneNumberText.isValid(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasInvalidEmailEntry: Bool {
|
||||||
|
emailEntries.contains { entry in
|
||||||
|
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return !trimmed.isEmpty && !EmailText.isValid(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasInvalidLinkEntry: Bool {
|
||||||
|
linkEntries.contains { entry in
|
||||||
|
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return !trimmed.isEmpty && !WebLinkText.isValid(trimmed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fullName: String {
|
private var fullName: String {
|
||||||
@ -83,7 +105,11 @@ struct AddContactSheet: View {
|
|||||||
entry: $entry,
|
entry: $entry,
|
||||||
valuePlaceholder: "+1 (555) 123-4567",
|
valuePlaceholder: "+1 (555) 123-4567",
|
||||||
labelSuggestions: ["Cell", "Work", "Home", "Main"],
|
labelSuggestions: ["Cell", "Work", "Home", "Main"],
|
||||||
keyboardType: .phonePad
|
keyboardType: .phonePad,
|
||||||
|
autocapitalization: .never,
|
||||||
|
formatValue: PhoneNumberText.formatted,
|
||||||
|
isValueValid: PhoneNumberText.isValid,
|
||||||
|
validationMessage: String.localized("Enter a valid phone number")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onDelete { indexSet in
|
.onDelete { indexSet in
|
||||||
@ -107,7 +133,9 @@ struct AddContactSheet: View {
|
|||||||
valuePlaceholder: "email@example.com",
|
valuePlaceholder: "email@example.com",
|
||||||
labelSuggestions: ["Work", "Personal", "Other"],
|
labelSuggestions: ["Work", "Personal", "Other"],
|
||||||
keyboardType: .emailAddress,
|
keyboardType: .emailAddress,
|
||||||
autocapitalization: .never
|
autocapitalization: .never,
|
||||||
|
isValueValid: EmailText.isValid,
|
||||||
|
validationMessage: String.localized("Enter a valid email address")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onDelete { indexSet in
|
.onDelete { indexSet in
|
||||||
@ -131,7 +159,9 @@ struct AddContactSheet: View {
|
|||||||
valuePlaceholder: "https://example.com",
|
valuePlaceholder: "https://example.com",
|
||||||
labelSuggestions: ["Website", "Portfolio", "LinkedIn", "Other"],
|
labelSuggestions: ["Website", "Portfolio", "LinkedIn", "Other"],
|
||||||
keyboardType: .URL,
|
keyboardType: .URL,
|
||||||
autocapitalization: .never
|
autocapitalization: .never,
|
||||||
|
isValueValid: WebLinkText.isValid,
|
||||||
|
validationMessage: String.localized("Enter a valid web link")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onDelete { indexSet in
|
.onDelete { indexSet in
|
||||||
@ -231,22 +261,49 @@ struct AddContactSheet: View {
|
|||||||
var orderIndex = 0
|
var orderIndex = 0
|
||||||
|
|
||||||
// Add phone entries
|
// Add phone entries
|
||||||
for entry in phoneEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
for entry in phoneEntries {
|
||||||
let field = ContactField(typeId: "phone", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
|
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
guard PhoneNumberText.isValid(trimmed) else { continue }
|
||||||
|
|
||||||
|
let field = ContactField(
|
||||||
|
typeId: "phone",
|
||||||
|
value: PhoneNumberText.normalizedForStorage(trimmed),
|
||||||
|
title: entry.label,
|
||||||
|
orderIndex: orderIndex
|
||||||
|
)
|
||||||
contactFields.append(field)
|
contactFields.append(field)
|
||||||
orderIndex += 1
|
orderIndex += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add email entries
|
// Add email entries
|
||||||
for entry in emailEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
for entry in emailEntries {
|
||||||
let field = ContactField(typeId: "email", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
|
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
guard EmailText.isValid(trimmed) else { continue }
|
||||||
|
|
||||||
|
let field = ContactField(
|
||||||
|
typeId: "email",
|
||||||
|
value: EmailText.normalizedForStorage(trimmed),
|
||||||
|
title: entry.label,
|
||||||
|
orderIndex: orderIndex
|
||||||
|
)
|
||||||
contactFields.append(field)
|
contactFields.append(field)
|
||||||
orderIndex += 1
|
orderIndex += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add link entries
|
// Add link entries
|
||||||
for entry in linkEntries where !entry.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
for entry in linkEntries {
|
||||||
let field = ContactField(typeId: "customLink", value: entry.value.trimmingCharacters(in: .whitespacesAndNewlines), title: entry.label, orderIndex: orderIndex)
|
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
guard WebLinkText.isValid(trimmed) else { continue }
|
||||||
|
|
||||||
|
let field = ContactField(
|
||||||
|
typeId: "customLink",
|
||||||
|
value: WebLinkText.normalizedForStorage(trimmed),
|
||||||
|
title: entry.label,
|
||||||
|
orderIndex: orderIndex
|
||||||
|
)
|
||||||
contactFields.append(field)
|
contactFields.append(field)
|
||||||
orderIndex += 1
|
orderIndex += 1
|
||||||
}
|
}
|
||||||
@ -327,31 +384,60 @@ private struct LabeledFieldRow: View {
|
|||||||
let labelSuggestions: [String]
|
let labelSuggestions: [String]
|
||||||
var keyboardType: UIKeyboardType = .default
|
var keyboardType: UIKeyboardType = .default
|
||||||
var autocapitalization: TextInputAutocapitalization = .sentences
|
var autocapitalization: TextInputAutocapitalization = .sentences
|
||||||
|
var formatValue: ((String) -> String)?
|
||||||
|
var isValueValid: ((String) -> Bool)?
|
||||||
|
var validationMessage: String?
|
||||||
|
|
||||||
|
private var trimmedValue: String {
|
||||||
|
entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showsValidationError: Bool {
|
||||||
|
guard let isValueValid else { return false }
|
||||||
|
guard !trimmedValue.isEmpty else { return false }
|
||||||
|
return !isValueValid(trimmedValue)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
// Label picker
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Menu {
|
// Label picker
|
||||||
ForEach(labelSuggestions, id: \.self) { suggestion in
|
Menu {
|
||||||
Button(suggestion) {
|
ForEach(labelSuggestions, id: \.self) { suggestion in
|
||||||
entry.label = suggestion
|
Button(suggestion) {
|
||||||
|
entry.label = suggestion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(entry.label)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.typography(.caption2)
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
.frame(width: 80, alignment: .leading)
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
|
||||||
Text(entry.label)
|
// Value field
|
||||||
.foregroundStyle(Color.accentColor)
|
TextField(valuePlaceholder, text: $entry.value)
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
.keyboardType(keyboardType)
|
||||||
.typography(.caption2)
|
.textInputAutocapitalization(autocapitalization)
|
||||||
.foregroundStyle(Color.secondary)
|
.onChange(of: entry.value) { _, newValue in
|
||||||
}
|
guard let formatValue else { return }
|
||||||
|
let formatted = formatValue(newValue)
|
||||||
|
if formatted != newValue {
|
||||||
|
entry.value = formatted
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 80, alignment: .leading)
|
|
||||||
|
|
||||||
// Value field
|
if showsValidationError, let validationMessage {
|
||||||
TextField(valuePlaceholder, text: $entry.value)
|
Text(validationMessage)
|
||||||
.keyboardType(keyboardType)
|
.typography(.caption)
|
||||||
.textInputAutocapitalization(autocapitalization)
|
.foregroundStyle(Color.Accent.red)
|
||||||
|
.padding(.leading, 80 + Design.Spacing.medium)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,10 +49,31 @@ struct ContactFieldEditorSheet: View {
|
|||||||
fieldType.id == "address"
|
fieldType.id == "address"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isPhoneField: Bool {
|
||||||
|
fieldType.id == "phone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isEmailField: Bool {
|
||||||
|
fieldType.id == "email"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isStrictURLField: Bool {
|
||||||
|
fieldType.id == "website" || fieldType.id == "customLink" || fieldType.id == "calendly"
|
||||||
|
}
|
||||||
|
|
||||||
private var isValid: Bool {
|
private var isValid: Bool {
|
||||||
if isAddressField {
|
if isAddressField {
|
||||||
return postalAddress.hasValue
|
return postalAddress.hasValue
|
||||||
}
|
}
|
||||||
|
if isPhoneField {
|
||||||
|
return PhoneNumberText.isValid(value)
|
||||||
|
}
|
||||||
|
if isEmailField {
|
||||||
|
return EmailText.isValid(value)
|
||||||
|
}
|
||||||
|
if isStrictURLField {
|
||||||
|
return WebLinkText.isValid(value)
|
||||||
|
}
|
||||||
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +104,33 @@ struct ContactFieldEditorSheet: View {
|
|||||||
.keyboardType(fieldType.keyboardType)
|
.keyboardType(fieldType.keyboardType)
|
||||||
.textInputAutocapitalization(fieldType.autocapitalization)
|
.textInputAutocapitalization(fieldType.autocapitalization)
|
||||||
.textContentType(textContentType)
|
.textContentType(textContentType)
|
||||||
|
.onChange(of: value) { _, newValue in
|
||||||
|
guard isPhoneField else { return }
|
||||||
|
let formatted = PhoneNumberText.formatted(newValue)
|
||||||
|
if formatted != newValue {
|
||||||
|
value = formatted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPhoneField,
|
||||||
|
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
!PhoneNumberText.isValid(value) {
|
||||||
|
Text(String.localized("Enter a valid phone number"))
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Accent.red)
|
||||||
|
} else if isEmailField,
|
||||||
|
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
!EmailText.isValid(value) {
|
||||||
|
Text(String.localized("Enter a valid email address"))
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Accent.red)
|
||||||
|
} else if isStrictURLField,
|
||||||
|
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
!WebLinkText.isValid(value) {
|
||||||
|
Text(String.localized("Enter a valid web link"))
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Accent.red)
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
@ -157,7 +205,14 @@ struct ContactFieldEditorSheet: View {
|
|||||||
// Save postal address as JSON
|
// Save postal address as JSON
|
||||||
onSave(postalAddress.encode(), title)
|
onSave(postalAddress.encode(), title)
|
||||||
} else {
|
} else {
|
||||||
onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title)
|
let valueToSave = isPhoneField
|
||||||
|
? PhoneNumberText.normalizedForStorage(value)
|
||||||
|
: isEmailField
|
||||||
|
? EmailText.normalizedForStorage(value)
|
||||||
|
: isStrictURLField
|
||||||
|
? WebLinkText.normalizedForStorage(value)
|
||||||
|
: value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
onSave(valueToSave, title)
|
||||||
}
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user