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

This commit is contained in:
Matt Bruce 2026-02-10 21:43:12 -06:00
parent fb5260f603
commit 2bd3722c1c
16 changed files with 144 additions and 34 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -110,7 +110,7 @@ struct BusinessCardApp: App {
.environment(appState)
.preferredColorScheme(appState.preferredColorScheme)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.textInputAutocapitalization(.sentences)
}
}
}

View File

@ -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

View File

@ -203,7 +203,7 @@ private struct KeyboardDismissModifier: ViewModifier {
func body(content: Content) -> some View {
content
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.textInputAutocapitalization(.sentences)
.scrollDismissesKeyboard(.interactively)
}
}

View File

@ -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

View File

@ -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

View File

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

View File

@ -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",

View File

@ -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)")

View File

@ -6,6 +6,14 @@
<array>
<string>appclips:$(APPCLIP_DOMAIN)</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.parent-application-identifiers</key>
<array>
<string>$(AppIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)</string>

View File

@ -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? {

View File

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

View File

@ -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:

View File

@ -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)