Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
fb5260f603
commit
2bd3722c1c
@ -5,9 +5,9 @@
|
|||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.860",
|
"blue" : "0.975",
|
||||||
"green" : "0.910",
|
"green" : "0.940",
|
||||||
"red" : "0.950"
|
"red" : "0.915"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.940",
|
"blue" : "0.965",
|
||||||
"green" : "0.960",
|
"green" : "0.940",
|
||||||
"red" : "0.970"
|
"red" : "0.920"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.950",
|
"blue" : "0.988",
|
||||||
"green" : "0.950",
|
"green" : "0.972",
|
||||||
"red" : "0.950"
|
"red" : "0.965"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
@ -110,7 +110,7 @@ struct BusinessCardApp: App {
|
|||||||
.environment(appState)
|
.environment(appState)
|
||||||
.preferredColorScheme(appState.preferredColorScheme)
|
.preferredColorScheme(appState.preferredColorScheme)
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.sentences)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +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
|
CLOUDKIT_SYNC_ENABLED = YES
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// APP CLIP CONFIGURATION
|
// APP CLIP CONFIGURATION
|
||||||
|
|||||||
@ -203,7 +203,7 @@ private struct KeyboardDismissModifier: ViewModifier {
|
|||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.sentences)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import CloudKit
|
|||||||
/// NOT a SwiftData model - uses raw CKRecord for ephemeral sharing.
|
/// NOT a SwiftData model - uses raw CKRecord for ephemeral sharing.
|
||||||
struct SharedCardRecord: Sendable {
|
struct SharedCardRecord: Sendable {
|
||||||
let recordID: CKRecord.ID
|
let recordID: CKRecord.ID
|
||||||
|
let displayName: String
|
||||||
|
let role: String
|
||||||
|
let company: String
|
||||||
let vCardData: String
|
let vCardData: String
|
||||||
let expiresAt: Date
|
let expiresAt: Date
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
@ -11,13 +14,28 @@ struct SharedCardRecord: Sendable {
|
|||||||
static let recordType = "SharedCard"
|
static let recordType = "SharedCard"
|
||||||
|
|
||||||
enum Field: String {
|
enum Field: String {
|
||||||
|
case displayName
|
||||||
|
case role
|
||||||
|
case company
|
||||||
case vCardData
|
case vCardData
|
||||||
|
case photoAsset
|
||||||
case expiresAt
|
case expiresAt
|
||||||
case createdAt
|
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.recordID = recordID
|
||||||
|
self.displayName = displayName
|
||||||
|
self.role = role
|
||||||
|
self.company = company
|
||||||
self.vCardData = vCardData
|
self.vCardData = vCardData
|
||||||
self.expiresAt = expiresAt
|
self.expiresAt = expiresAt
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
@ -29,14 +47,23 @@ struct SharedCardRecord: Sendable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
self.recordID = record.recordID
|
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.vCardData = vCardData
|
||||||
self.expiresAt = expiresAt
|
self.expiresAt = expiresAt
|
||||||
self.createdAt = (record[Field.createdAt.rawValue] as? Date) ?? record.creationDate ?? .now
|
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)
|
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
|
record[Field.vCardData.rawValue] = vCardData
|
||||||
|
if let photoAssetFileURL {
|
||||||
|
record[Field.photoAsset.rawValue] = CKAsset(fileURL: photoAssetFileURL)
|
||||||
|
}
|
||||||
record[Field.expiresAt.rawValue] = expiresAt
|
record[Field.expiresAt.rawValue] = expiresAt
|
||||||
record[Field.createdAt.rawValue] = createdAt
|
record[Field.createdAt.rawValue] = createdAt
|
||||||
return record
|
return record
|
||||||
|
|||||||
@ -21,14 +21,30 @@ struct SharedCardCloudKitService: SharedCardProviding {
|
|||||||
let vCardData = card.vCardFilePayload
|
let vCardData = card.vCardFilePayload
|
||||||
let expiresAt = Date.now.addingTimeInterval(TimeInterval(ttlDays * 24 * 60 * 60))
|
let expiresAt = Date.now.addingTimeInterval(TimeInterval(ttlDays * 24 * 60 * 60))
|
||||||
let recordID = CKRecord.ID(recordName: UUID().uuidString)
|
let recordID = CKRecord.ID(recordName: UUID().uuidString)
|
||||||
|
var photoAssetFileURL: URL?
|
||||||
|
|
||||||
let sharedCard = SharedCardRecord(
|
let sharedCard = SharedCardRecord(
|
||||||
recordID: recordID,
|
recordID: recordID,
|
||||||
|
displayName: card.vCardName.isEmpty ? card.fullName : card.vCardName,
|
||||||
|
role: card.role,
|
||||||
|
company: card.company,
|
||||||
vCardData: vCardData,
|
vCardData: vCardData,
|
||||||
expiresAt: expiresAt
|
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 {
|
do {
|
||||||
_ = try await database.save(record)
|
_ = try await database.save(record)
|
||||||
@ -67,6 +83,13 @@ struct SharedCardCloudKitService: SharedCardProviding {
|
|||||||
// Cleanup is best-effort; log but don't throw
|
// 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
|
// MARK: - Error Types
|
||||||
|
|||||||
@ -13,13 +13,8 @@ struct CardsHomeView: View {
|
|||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Background gradient
|
Color.AppBackground.base
|
||||||
LinearGradient(
|
.ignoresSafeArea()
|
||||||
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
if cardStore.cards.isEmpty {
|
if cardStore.cards.isEmpty {
|
||||||
EmptyCardsView(onCreateCard: { showingCreateCard = true })
|
EmptyCardsView(onCreateCard: { showingCreateCard = true })
|
||||||
|
|||||||
@ -51,8 +51,8 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
backgroundColor: Color.AppBackground.secondary,
|
backgroundColor: Color.AppBackground.elevated,
|
||||||
borderColor: .clear
|
borderColor: AppBorder.standard
|
||||||
) {
|
) {
|
||||||
SettingsSegmentedPicker(
|
SettingsSegmentedPicker(
|
||||||
title: "Theme",
|
title: "Theme",
|
||||||
@ -83,8 +83,8 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
backgroundColor: Color.AppBackground.secondary,
|
backgroundColor: Color.AppBackground.elevated,
|
||||||
borderColor: .clear
|
borderColor: AppBorder.standard
|
||||||
) {
|
) {
|
||||||
SettingsCardRow {
|
SettingsCardRow {
|
||||||
HStack {
|
HStack {
|
||||||
@ -154,8 +154,8 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
backgroundColor: Color.AppBackground.secondary,
|
backgroundColor: Color.AppBackground.elevated,
|
||||||
borderColor: .clear
|
borderColor: AppBorder.standard
|
||||||
) {
|
) {
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Enable Debug Premium",
|
title: "Enable Debug Premium",
|
||||||
|
|||||||
@ -5,6 +5,11 @@ import Bedrock
|
|||||||
struct ContactFieldEditorSheet: View {
|
struct ContactFieldEditorSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private enum ActiveField: Hashable {
|
||||||
|
case value
|
||||||
|
case title
|
||||||
|
}
|
||||||
|
|
||||||
let fieldType: ContactFieldType
|
let fieldType: ContactFieldType
|
||||||
let initialValue: String
|
let initialValue: String
|
||||||
let initialTitle: String
|
let initialTitle: String
|
||||||
@ -15,6 +20,7 @@ struct ContactFieldEditorSheet: View {
|
|||||||
@State private var value: String
|
@State private var value: String
|
||||||
@State private var title: String
|
@State private var title: String
|
||||||
@State private var postalAddress: PostalAddress
|
@State private var postalAddress: PostalAddress
|
||||||
|
@FocusState private var activeField: ActiveField?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
fieldType: ContactFieldType,
|
fieldType: ContactFieldType,
|
||||||
@ -101,6 +107,7 @@ struct ContactFieldEditorSheet: View {
|
|||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
TextField(fieldType.valuePlaceholder, text: $value)
|
TextField(fieldType.valuePlaceholder, text: $value)
|
||||||
|
.focused($activeField, equals: .value)
|
||||||
.keyboardType(fieldType.keyboardType)
|
.keyboardType(fieldType.keyboardType)
|
||||||
.textInputAutocapitalization(fieldType.autocapitalization)
|
.textInputAutocapitalization(fieldType.autocapitalization)
|
||||||
.textContentType(textContentType)
|
.textContentType(textContentType)
|
||||||
@ -143,6 +150,7 @@ struct ContactFieldEditorSheet: View {
|
|||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
TextField(String(localized: "e.g. Work, Personal"), text: $title)
|
TextField(String(localized: "e.g. Work, Personal"), text: $title)
|
||||||
|
.focused($activeField, equals: .title)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@ -186,6 +194,10 @@ struct ContactFieldEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
|
.contentShape(.rect)
|
||||||
|
.onTapGesture {
|
||||||
|
activeField = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(Color.AppBackground.base)
|
.background(Color.AppBackground.base)
|
||||||
.navigationTitle(isEditing ? "Edit \(fieldType.displayName)" : "Add \(fieldType.displayName)")
|
.navigationTitle(isEditing ? "Edit \(fieldType.displayName)" : "Add \(fieldType.displayName)")
|
||||||
|
|||||||
@ -6,6 +6,14 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>appclips:$(APPCLIP_DOMAIN)</string>
|
<string>appclips:$(APPCLIP_DOMAIN)</string>
|
||||||
</array>
|
</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>
|
<key>com.apple.developer.parent-application-identifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)</string>
|
<string>$(AppIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)</string>
|
||||||
|
|||||||
@ -9,17 +9,30 @@ struct SharedCardSnapshot: Sendable {
|
|||||||
let company: String
|
let company: String
|
||||||
let photoData: Data?
|
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.recordName = recordName
|
||||||
self.vCardData = vCardData
|
self.vCardData = vCardData
|
||||||
|
|
||||||
// Parse display fields from vCard
|
// Parse display fields from vCard
|
||||||
let lines = vCardData.components(separatedBy: .newlines)
|
let lines = vCardData.components(separatedBy: .newlines)
|
||||||
self.displayName = Self.parseField("FN:", from: lines) ?? "Contact"
|
let parsedDisplayName = Self.parseField("FN:", from: lines) ?? "Contact"
|
||||||
self.role = Self.parseField("TITLE:", from: lines) ?? ""
|
let parsedRole = Self.parseField("TITLE:", from: lines) ?? ""
|
||||||
self.company = Self.parseField("ORG:", from: lines)?
|
let parsedCompany = Self.parseField("ORG:", from: lines)?
|
||||||
.components(separatedBy: ";").first ?? ""
|
.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? {
|
private static func parseField(_ prefix: String, from lines: [String]) -> String? {
|
||||||
|
|||||||
@ -29,6 +29,33 @@ struct ClipCloudKitService: Sendable {
|
|||||||
throw ClipError.expired
|
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 fetchFailed
|
||||||
case invalidRecord
|
case invalidRecord
|
||||||
case expired
|
case expired
|
||||||
|
case deleteFailed
|
||||||
case contactSaveFailed
|
case contactSaveFailed
|
||||||
case contactsAccessDenied
|
case contactsAccessDenied
|
||||||
|
|
||||||
@ -16,6 +17,8 @@ enum ClipError: Error, LocalizedError {
|
|||||||
return String(localized: "Invalid card data")
|
return String(localized: "Invalid card data")
|
||||||
case .expired:
|
case .expired:
|
||||||
return String(localized: "This card has expired")
|
return String(localized: "This card has expired")
|
||||||
|
case .deleteFailed:
|
||||||
|
return String(localized: "Could not remove shared card")
|
||||||
case .contactSaveFailed:
|
case .contactSaveFailed:
|
||||||
return String(localized: "Failed to save contact")
|
return String(localized: "Failed to save contact")
|
||||||
case .contactsAccessDenied:
|
case .contactsAccessDenied:
|
||||||
|
|||||||
@ -48,6 +48,8 @@ final class ClipCardStore {
|
|||||||
guard let snapshot else { return }
|
guard let snapshot else { return }
|
||||||
do {
|
do {
|
||||||
try await contactSave.saveContact(vCardData: snapshot.vCardData)
|
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
|
state = .saved
|
||||||
} catch {
|
} catch {
|
||||||
state = .error(error.localizedDescription)
|
state = .error(error.localizedDescription)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user