From 2bd3722c1cad55252ade462d8fa5e02d22e00e71 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 10 Feb 2026 21:43:12 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../Contents.json | 6 ++-- .../AppBackgroundBase.colorset/Contents.json | 6 ++-- .../Contents.json | 6 ++-- BusinessCard/BusinessCardApp.swift | 2 +- BusinessCard/Configuration/Base.xcconfig | 2 +- BusinessCard/Design/DesignConstants.swift | 2 +- BusinessCard/Models/SharedCardRecord.swift | 31 +++++++++++++++++-- .../Services/SharedCardCloudKitService.swift | 25 ++++++++++++++- BusinessCard/Views/CardsHomeView.swift | 9 ++---- BusinessCard/Views/SettingsView.swift | 12 +++---- .../Sheets/ContactFieldEditorSheet.swift | 12 +++++++ .../BusinessCardClip.entitlements | 8 +++++ .../Models/SharedCardSnapshot.swift | 23 +++++++++++--- .../Services/ClipCloudKitService.swift | 29 ++++++++++++++++- BusinessCardClip/Services/ClipError.swift | 3 ++ BusinessCardClip/State/ClipCardStore.swift | 2 ++ 16 files changed, 144 insertions(+), 34 deletions(-) diff --git a/BusinessCard/Assets.xcassets/AppBackgroundAccent.colorset/Contents.json b/BusinessCard/Assets.xcassets/AppBackgroundAccent.colorset/Contents.json index 46886a4..a460be2 100644 --- a/BusinessCard/Assets.xcassets/AppBackgroundAccent.colorset/Contents.json +++ b/BusinessCard/Assets.xcassets/AppBackgroundAccent.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.860", - "green" : "0.910", - "red" : "0.950" + "blue" : "0.975", + "green" : "0.940", + "red" : "0.915" } }, "idiom" : "universal" diff --git a/BusinessCard/Assets.xcassets/AppBackgroundBase.colorset/Contents.json b/BusinessCard/Assets.xcassets/AppBackgroundBase.colorset/Contents.json index 820d3cd..36123a8 100644 --- a/BusinessCard/Assets.xcassets/AppBackgroundBase.colorset/Contents.json +++ b/BusinessCard/Assets.xcassets/AppBackgroundBase.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.940", - "green" : "0.960", - "red" : "0.970" + "blue" : "0.965", + "green" : "0.940", + "red" : "0.920" } }, "idiom" : "universal" diff --git a/BusinessCard/Assets.xcassets/AppBackgroundSecondary.colorset/Contents.json b/BusinessCard/Assets.xcassets/AppBackgroundSecondary.colorset/Contents.json index ade7426..191284c 100644 --- a/BusinessCard/Assets.xcassets/AppBackgroundSecondary.colorset/Contents.json +++ b/BusinessCard/Assets.xcassets/AppBackgroundSecondary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.950", - "green" : "0.950", - "red" : "0.950" + "blue" : "0.988", + "green" : "0.972", + "red" : "0.965" } }, "idiom" : "universal" diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift index ed76070..e63d5ed 100644 --- a/BusinessCard/BusinessCardApp.swift +++ b/BusinessCard/BusinessCardApp.swift @@ -110,7 +110,7 @@ struct BusinessCardApp: App { .environment(appState) .preferredColorScheme(appState.preferredColorScheme) .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) + .textInputAutocapitalization(.sentences) } } } diff --git a/BusinessCard/Configuration/Base.xcconfig b/BusinessCard/Configuration/Base.xcconfig index 9811d9d..e56e3ea 100644 --- a/BusinessCard/Configuration/Base.xcconfig +++ b/BusinessCard/Configuration/Base.xcconfig @@ -26,7 +26,7 @@ UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests // Entitlement identifiers APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME) CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME) -CLOUDKIT_SYNC_ENABLED = NO +CLOUDKIT_SYNC_ENABLED = YES // ============================================================================= // APP CLIP CONFIGURATION diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift index c0187ef..82cd3e9 100644 --- a/BusinessCard/Design/DesignConstants.swift +++ b/BusinessCard/Design/DesignConstants.swift @@ -203,7 +203,7 @@ private struct KeyboardDismissModifier: ViewModifier { func body(content: Content) -> some View { content .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) + .textInputAutocapitalization(.sentences) .scrollDismissesKeyboard(.interactively) } } diff --git a/BusinessCard/Models/SharedCardRecord.swift b/BusinessCard/Models/SharedCardRecord.swift index ae90b22..8cff8ae 100644 --- a/BusinessCard/Models/SharedCardRecord.swift +++ b/BusinessCard/Models/SharedCardRecord.swift @@ -4,6 +4,9 @@ import CloudKit /// NOT a SwiftData model - uses raw CKRecord for ephemeral sharing. struct SharedCardRecord: Sendable { let recordID: CKRecord.ID + let displayName: String + let role: String + let company: String let vCardData: String let expiresAt: Date let createdAt: Date @@ -11,13 +14,28 @@ struct SharedCardRecord: Sendable { static let recordType = "SharedCard" enum Field: String { + case displayName + case role + case company case vCardData + case photoAsset case expiresAt case createdAt } - init(recordID: CKRecord.ID, vCardData: String, expiresAt: Date, createdAt: Date = .now) { + init( + recordID: CKRecord.ID, + displayName: String, + role: String, + company: String, + vCardData: String, + expiresAt: Date, + createdAt: Date = .now + ) { self.recordID = recordID + self.displayName = displayName + self.role = role + self.company = company self.vCardData = vCardData self.expiresAt = expiresAt self.createdAt = createdAt @@ -29,14 +47,23 @@ struct SharedCardRecord: Sendable { return nil } self.recordID = record.recordID + self.displayName = (record[Field.displayName.rawValue] as? String) ?? "" + self.role = (record[Field.role.rawValue] as? String) ?? "" + self.company = (record[Field.company.rawValue] as? String) ?? "" self.vCardData = vCardData self.expiresAt = expiresAt self.createdAt = (record[Field.createdAt.rawValue] as? Date) ?? record.creationDate ?? .now } - func toCKRecord() -> CKRecord { + func toCKRecord(photoAssetFileURL: URL? = nil) -> CKRecord { let record = CKRecord(recordType: Self.recordType, recordID: recordID) + record[Field.displayName.rawValue] = displayName + record[Field.role.rawValue] = role + record[Field.company.rawValue] = company record[Field.vCardData.rawValue] = vCardData + if let photoAssetFileURL { + record[Field.photoAsset.rawValue] = CKAsset(fileURL: photoAssetFileURL) + } record[Field.expiresAt.rawValue] = expiresAt record[Field.createdAt.rawValue] = createdAt return record diff --git a/BusinessCard/Services/SharedCardCloudKitService.swift b/BusinessCard/Services/SharedCardCloudKitService.swift index 17b6c47..3d613e2 100644 --- a/BusinessCard/Services/SharedCardCloudKitService.swift +++ b/BusinessCard/Services/SharedCardCloudKitService.swift @@ -21,14 +21,30 @@ struct SharedCardCloudKitService: SharedCardProviding { let vCardData = card.vCardFilePayload let expiresAt = Date.now.addingTimeInterval(TimeInterval(ttlDays * 24 * 60 * 60)) let recordID = CKRecord.ID(recordName: UUID().uuidString) + var photoAssetFileURL: URL? let sharedCard = SharedCardRecord( recordID: recordID, + displayName: card.vCardName.isEmpty ? card.fullName : card.vCardName, + role: card.role, + company: card.company, vCardData: vCardData, expiresAt: expiresAt ) - let record = sharedCard.toCKRecord() + do { + photoAssetFileURL = try makePhotoAssetFile(from: card.photoData) + } catch { + throw SharedCardError.uploadFailed(error) + } + + defer { + if let photoAssetFileURL { + try? FileManager.default.removeItem(at: photoAssetFileURL) + } + } + + let record = sharedCard.toCKRecord(photoAssetFileURL: photoAssetFileURL) do { _ = try await database.save(record) @@ -67,6 +83,13 @@ struct SharedCardCloudKitService: SharedCardProviding { // Cleanup is best-effort; log but don't throw } } + + private func makePhotoAssetFile(from photoData: Data?) throws -> URL? { + guard let photoData, !photoData.isEmpty else { return nil } + let fileURL = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).jpg") + try photoData.write(to: fileURL, options: .atomic) + return fileURL + } } // MARK: - Error Types diff --git a/BusinessCard/Views/CardsHomeView.swift b/BusinessCard/Views/CardsHomeView.swift index 5b179eb..d912e8b 100644 --- a/BusinessCard/Views/CardsHomeView.swift +++ b/BusinessCard/Views/CardsHomeView.swift @@ -13,13 +13,8 @@ struct CardsHomeView: View { NavigationStack { ZStack { - // Background gradient - LinearGradient( - colors: [Color.AppBackground.base, Color.AppBackground.accent], - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() + Color.AppBackground.base + .ignoresSafeArea() if cardStore.cards.isEmpty { EmptyCardsView(onCreateCard: { showingCreateCard = true }) diff --git a/BusinessCard/Views/SettingsView.swift b/BusinessCard/Views/SettingsView.swift index 9e97737..1ba79a7 100644 --- a/BusinessCard/Views/SettingsView.swift +++ b/BusinessCard/Views/SettingsView.swift @@ -51,8 +51,8 @@ struct SettingsView: View { ) SettingsCard( - backgroundColor: Color.AppBackground.secondary, - borderColor: .clear + backgroundColor: Color.AppBackground.elevated, + borderColor: AppBorder.standard ) { SettingsSegmentedPicker( title: "Theme", @@ -83,8 +83,8 @@ struct SettingsView: View { ) SettingsCard( - backgroundColor: Color.AppBackground.secondary, - borderColor: .clear + backgroundColor: Color.AppBackground.elevated, + borderColor: AppBorder.standard ) { SettingsCardRow { HStack { @@ -154,8 +154,8 @@ struct SettingsView: View { ) SettingsCard( - backgroundColor: Color.AppBackground.secondary, - borderColor: .clear + backgroundColor: Color.AppBackground.elevated, + borderColor: AppBorder.standard ) { SettingsToggle( title: "Enable Debug Premium", diff --git a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift index 42f0cd0..537dfc7 100644 --- a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift +++ b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift @@ -5,6 +5,11 @@ import Bedrock struct ContactFieldEditorSheet: View { @Environment(\.dismiss) private var dismiss + private enum ActiveField: Hashable { + case value + case title + } + let fieldType: ContactFieldType let initialValue: String let initialTitle: String @@ -15,6 +20,7 @@ struct ContactFieldEditorSheet: View { @State private var value: String @State private var title: String @State private var postalAddress: PostalAddress + @FocusState private var activeField: ActiveField? init( fieldType: ContactFieldType, @@ -101,6 +107,7 @@ struct ContactFieldEditorSheet: View { .foregroundStyle(Color.Text.primary) TextField(fieldType.valuePlaceholder, text: $value) + .focused($activeField, equals: .value) .keyboardType(fieldType.keyboardType) .textInputAutocapitalization(fieldType.autocapitalization) .textContentType(textContentType) @@ -143,6 +150,7 @@ struct ContactFieldEditorSheet: View { .foregroundStyle(Color.Text.primary) TextField(String(localized: "e.g. Work, Personal"), text: $title) + .focused($activeField, equals: .title) Divider() @@ -186,6 +194,10 @@ struct ContactFieldEditorSheet: View { } } .padding(Design.Spacing.large) + .contentShape(.rect) + .onTapGesture { + activeField = nil + } } .background(Color.AppBackground.base) .navigationTitle(isEditing ? "Edit \(fieldType.displayName)" : "Add \(fieldType.displayName)") diff --git a/BusinessCardClip/BusinessCardClip.entitlements b/BusinessCardClip/BusinessCardClip.entitlements index 1d8b984..e23bfd2 100644 --- a/BusinessCardClip/BusinessCardClip.entitlements +++ b/BusinessCardClip/BusinessCardClip.entitlements @@ -6,6 +6,14 @@ appclips:$(APPCLIP_DOMAIN) + com.apple.developer.icloud-container-identifiers + + $(CLOUDKIT_CONTAINER_IDENTIFIER) + + com.apple.developer.icloud-services + + CloudKit + com.apple.developer.parent-application-identifiers $(AppIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER) diff --git a/BusinessCardClip/Models/SharedCardSnapshot.swift b/BusinessCardClip/Models/SharedCardSnapshot.swift index 39a7f24..ca8a159 100644 --- a/BusinessCardClip/Models/SharedCardSnapshot.swift +++ b/BusinessCardClip/Models/SharedCardSnapshot.swift @@ -9,17 +9,30 @@ struct SharedCardSnapshot: Sendable { let company: String let photoData: Data? - init(recordName: String, vCardData: String) { + init( + recordName: String, + vCardData: String, + displayName: String? = nil, + role: String? = nil, + company: String? = nil, + photoData: Data? = nil + ) { self.recordName = recordName self.vCardData = vCardData // Parse display fields from vCard let lines = vCardData.components(separatedBy: .newlines) - self.displayName = Self.parseField("FN:", from: lines) ?? "Contact" - self.role = Self.parseField("TITLE:", from: lines) ?? "" - self.company = Self.parseField("ORG:", from: lines)? + let parsedDisplayName = Self.parseField("FN:", from: lines) ?? "Contact" + let parsedRole = Self.parseField("TITLE:", from: lines) ?? "" + let parsedCompany = Self.parseField("ORG:", from: lines)? .components(separatedBy: ";").first ?? "" - self.photoData = Self.parsePhoto(from: lines) + let parsedPhotoData = Self.parsePhoto(from: lines) + + let cleanedDisplayName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.displayName = cleanedDisplayName.isEmpty ? parsedDisplayName : cleanedDisplayName + self.role = role ?? parsedRole + self.company = company ?? parsedCompany + self.photoData = photoData ?? parsedPhotoData } private static func parseField(_ prefix: String, from lines: [String]) -> String? { diff --git a/BusinessCardClip/Services/ClipCloudKitService.swift b/BusinessCardClip/Services/ClipCloudKitService.swift index 8df07cb..eb18429 100644 --- a/BusinessCardClip/Services/ClipCloudKitService.swift +++ b/BusinessCardClip/Services/ClipCloudKitService.swift @@ -29,6 +29,33 @@ struct ClipCloudKitService: Sendable { throw ClipError.expired } - return SharedCardSnapshot(recordName: recordName, vCardData: vCardData) + let displayName = record["displayName"] as? String + let role = record["role"] as? String + let company = record["company"] as? String + let photoData = dataFromPhotoAsset(record["photoAsset"] as? CKAsset) + + return SharedCardSnapshot( + recordName: recordName, + vCardData: vCardData, + displayName: displayName, + role: role, + company: company, + photoData: photoData + ) + } + + /// Deletes a shared card from CloudKit after it is saved to Contacts. + func deleteSharedCard(recordName: String) async throws { + let recordID = CKRecord.ID(recordName: recordName) + do { + _ = try await database.deleteRecord(withID: recordID) + } catch { + throw ClipError.deleteFailed + } + } + + private func dataFromPhotoAsset(_ asset: CKAsset?) -> Data? { + guard let fileURL = asset?.fileURL else { return nil } + return try? Data(contentsOf: fileURL) } } diff --git a/BusinessCardClip/Services/ClipError.swift b/BusinessCardClip/Services/ClipError.swift index e3707a9..c0a99ff 100644 --- a/BusinessCardClip/Services/ClipError.swift +++ b/BusinessCardClip/Services/ClipError.swift @@ -5,6 +5,7 @@ enum ClipError: Error, LocalizedError { case fetchFailed case invalidRecord case expired + case deleteFailed case contactSaveFailed case contactsAccessDenied @@ -16,6 +17,8 @@ enum ClipError: Error, LocalizedError { return String(localized: "Invalid card data") case .expired: return String(localized: "This card has expired") + case .deleteFailed: + return String(localized: "Could not remove shared card") case .contactSaveFailed: return String(localized: "Failed to save contact") case .contactsAccessDenied: diff --git a/BusinessCardClip/State/ClipCardStore.swift b/BusinessCardClip/State/ClipCardStore.swift index f8a5adb..b847d84 100644 --- a/BusinessCardClip/State/ClipCardStore.swift +++ b/BusinessCardClip/State/ClipCardStore.swift @@ -48,6 +48,8 @@ final class ClipCardStore { guard let snapshot else { return } do { try await contactSave.saveContact(vCardData: snapshot.vCardData) + // Best-effort cleanup so saved cards do not linger in public CloudKit. + try? await cloudKit.deleteSharedCard(recordName: snapshot.recordName) state = .saved } catch { state = .error(error.localizedDescription)