Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
fb5260f603
commit
2bd3722c1c
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -110,7 +110,7 @@ struct BusinessCardApp: App {
|
||||
.environment(appState)
|
||||
.preferredColorScheme(appState.preferredColorScheme)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -203,7 +203,7 @@ private struct KeyboardDismissModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user