Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
f4ea7c0ed7
commit
80439171bb
@ -10,7 +10,7 @@
|
||||
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; };
|
||||
EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69E9262F3D4B5700592220 /* Bedrock */; };
|
||||
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; };
|
||||
EAAE892A2F12DE110075BC8A /* BusinessCardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
EAAE892A2F12DE110075BC8A /* BusinessCardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -806,7 +806,7 @@
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -840,7 +840,7 @@
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -875,7 +875,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusinessCardClip/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||
@ -887,7 +887,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@ -914,7 +914,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusinessCardClip/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||
@ -926,7 +926,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
||||
@ -94,7 +94,7 @@ struct BusinessCardApp: App {
|
||||
self.modelContainer = container
|
||||
let context = container.mainContext
|
||||
self._appState = State(initialValue: AppState(modelContext: context))
|
||||
|
||||
|
||||
// Activate WatchConnectivity session
|
||||
_ = WatchConnectivityService.shared
|
||||
}
|
||||
|
||||
@ -3,11 +3,11 @@ import SwiftData
|
||||
|
||||
@Model
|
||||
final class AppSettings {
|
||||
var id: UUID
|
||||
var preferredShareActionRawValue: String
|
||||
var defaultFollowUpPresetRawValue: String
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var id: UUID = UUID()
|
||||
var preferredShareActionRawValue: String = "shareSheet"
|
||||
var defaultFollowUpPresetRawValue: String = "none"
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
|
||||
@ -4,31 +4,31 @@ import SwiftUI
|
||||
|
||||
@Model
|
||||
final class BusinessCard {
|
||||
var id: UUID
|
||||
var role: String
|
||||
var company: String
|
||||
var label: String
|
||||
var isDefault: Bool
|
||||
var themeName: String
|
||||
var layoutStyleRawValue: String
|
||||
var headerLayoutRawValue: String
|
||||
var avatarSystemName: String
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
|
||||
var id: UUID = UUID()
|
||||
var role: String = ""
|
||||
var company: String = ""
|
||||
var label: String = "Work"
|
||||
var isDefault: Bool = false
|
||||
var themeName: String = "Coral"
|
||||
var layoutStyleRawValue: String = "stacked"
|
||||
var headerLayoutRawValue: String = "profileBanner"
|
||||
var avatarSystemName: String = "person.crop.circle"
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
|
||||
// Enhanced profile fields
|
||||
var prefix: String
|
||||
var firstName: String
|
||||
var middleName: String
|
||||
var lastName: String
|
||||
var suffix: String
|
||||
var maidenName: String
|
||||
var preferredName: String
|
||||
var pronouns: String
|
||||
var department: String
|
||||
var headline: String
|
||||
var bio: String
|
||||
var accreditations: String
|
||||
var prefix: String = ""
|
||||
var firstName: String = ""
|
||||
var middleName: String = ""
|
||||
var lastName: String = ""
|
||||
var suffix: String = ""
|
||||
var maidenName: String = ""
|
||||
var preferredName: String = ""
|
||||
var pronouns: String = ""
|
||||
var department: String = ""
|
||||
var headline: String = ""
|
||||
var bio: String = ""
|
||||
var accreditations: String = ""
|
||||
|
||||
// Profile photo stored as Data (JPEG)
|
||||
@Attribute(.externalStorage) var photoData: Data?
|
||||
|
||||
@ -3,27 +3,27 @@ import SwiftData
|
||||
|
||||
@Model
|
||||
final class Contact {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: String
|
||||
var company: String
|
||||
var avatarSystemName: String
|
||||
var lastSharedDate: Date
|
||||
var cardLabel: String
|
||||
|
||||
var id: UUID = UUID()
|
||||
var name: String = ""
|
||||
var role: String = ""
|
||||
var company: String = ""
|
||||
var avatarSystemName: String = "person.crop.circle"
|
||||
var lastSharedDate: Date = Date()
|
||||
var cardLabel: String = "Work"
|
||||
|
||||
// Contact annotations
|
||||
var notes: String
|
||||
var tags: String // Comma-separated tags
|
||||
var notes: String = ""
|
||||
var tags: String = "" // Comma-separated tags
|
||||
var followUpDate: Date?
|
||||
var email: String // Legacy single email (kept for migration/fallback)
|
||||
var phone: String // Legacy single phone (kept for migration/fallback)
|
||||
|
||||
var email: String = "" // Legacy single email (kept for migration/fallback)
|
||||
var phone: String = "" // Legacy single phone (kept for migration/fallback)
|
||||
|
||||
// Multiple contact fields (phones, emails, links with labels)
|
||||
@Relationship(deleteRule: .cascade, inverse: \ContactField.contact)
|
||||
var contactFields: [ContactField]?
|
||||
|
||||
|
||||
// If this is a received card (scanned from someone else)
|
||||
var isReceivedCard: Bool
|
||||
var isReceivedCard: Bool = false
|
||||
|
||||
// Profile photo
|
||||
@Attribute(.externalStorage) var photoData: Data?
|
||||
|
||||
@ -18,4 +18,6 @@ struct SyncableCard: Codable, Identifiable {
|
||||
var instagram: String
|
||||
/// Pre-generated QR code PNG data (CoreImage not available on watchOS).
|
||||
var qrCodeImageData: Data?
|
||||
/// Pre-generated App Clip URL QR code PNG data.
|
||||
var appClipQRCodeImageData: Data?
|
||||
}
|
||||
|
||||
@ -164,6 +164,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Checking..." : {
|
||||
"comment" : "Placeholder text while waiting to check the sync status.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Choose a card in the My Cards tab to start sharing." : {
|
||||
|
||||
},
|
||||
@ -408,6 +412,14 @@
|
||||
},
|
||||
"Home" : {
|
||||
|
||||
},
|
||||
"iCloud Sync" : {
|
||||
"comment" : "A label displayed above the iCloud sync section in the settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"iCloud sync status" : {
|
||||
"comment" : "An accessibility hint describing the purpose of the \"iCloud sync status\" label.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Images & layout" : {
|
||||
"localizations" : {
|
||||
@ -442,6 +454,10 @@
|
||||
},
|
||||
"Links" : {
|
||||
|
||||
},
|
||||
"Local only" : {
|
||||
"comment" : "Status text indicating that the app is using only local storage (CloudKit sync is disabled).",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Logo" : {
|
||||
|
||||
@ -825,6 +841,18 @@
|
||||
},
|
||||
"Support & Funding" : {
|
||||
|
||||
},
|
||||
"Synced" : {
|
||||
"comment" : "Text describing a successful sync with iCloud.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Synced %@" : {
|
||||
"comment" : "Text that appears in the status bar or other user-facing interface to indicate that data is synced with the cloud. The argument is a relative time description (e.g. \"2 days ago\").",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Syncing..." : {
|
||||
"comment" : "Status text indicating that the app is currently syncing with iCloud.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Tags" : {
|
||||
|
||||
|
||||
156
BusinessCard/Services/CloudKitSyncMonitor.swift
Normal file
156
BusinessCard/Services/CloudKitSyncMonitor.swift
Normal file
@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Observation
|
||||
import Bedrock
|
||||
|
||||
/// Tracks CloudKit sync status for SwiftData-backed iCloud sync.
|
||||
/// Observes NSPersistentCloudKitContainer events to show sync state to the user.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CloudKitSyncMonitor {
|
||||
/// Current sync state for UI display.
|
||||
enum SyncState: Equatable {
|
||||
case unknown
|
||||
case syncing
|
||||
case synced(lastExportDate: Date?)
|
||||
case error(String)
|
||||
case disabled // CloudKit sync not enabled
|
||||
}
|
||||
|
||||
var state: SyncState = .unknown
|
||||
var lastExportDate: Date?
|
||||
var lastError: String?
|
||||
|
||||
private nonisolated(unsafe) var observer: NSObjectProtocol?
|
||||
|
||||
init(isCloudKitEnabled: Bool) {
|
||||
if !isCloudKitEnabled {
|
||||
state = .disabled
|
||||
Design.debugLog("CloudKitSyncMonitor: CloudKit sync disabled (local-only storage)")
|
||||
return
|
||||
}
|
||||
|
||||
Design.debugLog("CloudKitSyncMonitor: Observing NSPersistentCloudKitContainer events")
|
||||
observer = NotificationCenter.default.addObserver(
|
||||
forName: NSPersistentCloudKitContainer.eventChangedNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
let info = Self.extractEventInfo(from: notification)
|
||||
Task { @MainActor in
|
||||
self?.handleEventInfo(info)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial state - assume syncing until we get first event
|
||||
state = .syncing
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EventInfo: Sendable {
|
||||
let typeRaw: Int
|
||||
let endDate: Date?
|
||||
let startDate: Date
|
||||
let errorMessage: String?
|
||||
}
|
||||
|
||||
private static nonisolated func extractEventInfo(from notification: Notification) -> EventInfo? {
|
||||
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
|
||||
return nil
|
||||
}
|
||||
return EventInfo(
|
||||
typeRaw: event.type.rawValue,
|
||||
endDate: event.endDate,
|
||||
startDate: event.startDate,
|
||||
errorMessage: event.error?.localizedDescription
|
||||
)
|
||||
}
|
||||
|
||||
private func handleEventInfo(_ info: EventInfo?) {
|
||||
guard let info else { return }
|
||||
|
||||
if let errorMsg = info.errorMessage {
|
||||
lastError = errorMsg
|
||||
state = .error(errorMsg)
|
||||
Design.debugLog("CloudKitSyncMonitor: Sync FAILED - type \(info.typeRaw) error: \(errorMsg)")
|
||||
return
|
||||
}
|
||||
|
||||
lastError = nil
|
||||
|
||||
switch info.typeRaw {
|
||||
case 0: // setup
|
||||
state = .syncing
|
||||
Design.debugLog("CloudKitSyncMonitor: Setup in progress")
|
||||
case 1: // import
|
||||
state = .synced(lastExportDate: lastExportDate)
|
||||
let duration = eventDuration(start: info.startDate, end: info.endDate)
|
||||
Design.debugLog("CloudKitSyncMonitor: Import SUCCESS\(duration)")
|
||||
case 2: // export
|
||||
lastExportDate = info.endDate ?? Date()
|
||||
state = .synced(lastExportDate: lastExportDate)
|
||||
let duration = eventDuration(start: info.startDate, end: info.endDate)
|
||||
Design.debugLog("CloudKitSyncMonitor: Export SUCCESS\(duration)")
|
||||
default:
|
||||
state = .synced(lastExportDate: lastExportDate)
|
||||
Design.debugLog("CloudKitSyncMonitor: Event type \(info.typeRaw) completed")
|
||||
}
|
||||
}
|
||||
|
||||
private func eventDuration(start: Date, end: Date?) -> String {
|
||||
guard let end else { return "" }
|
||||
let ms = end.timeIntervalSince(start) * 1000
|
||||
return " (\(String(format: "%.0f", ms))ms)"
|
||||
}
|
||||
|
||||
/// User-friendly status text for Settings and indicators.
|
||||
var statusText: String {
|
||||
switch state {
|
||||
case .unknown:
|
||||
return String(localized: "Checking...")
|
||||
case .syncing:
|
||||
return String(localized: "Syncing...")
|
||||
case .synced(let date):
|
||||
if let date {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
let relative = formatter.localizedString(for: date, relativeTo: Date())
|
||||
return String(localized: "Synced \(relative)")
|
||||
}
|
||||
return String(localized: "Synced")
|
||||
case .error(let message):
|
||||
return message
|
||||
case .disabled:
|
||||
return String(localized: "Local only")
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol for current state.
|
||||
var statusIcon: String {
|
||||
switch state {
|
||||
case .unknown, .syncing:
|
||||
return "arrow.triangle.2.circlepath"
|
||||
case .synced:
|
||||
return "checkmark.icloud"
|
||||
case .error:
|
||||
return "exclamationmark.icloud"
|
||||
case .disabled:
|
||||
return "internaldrive"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether sync appears healthy (synced or syncing).
|
||||
var isHealthy: Bool {
|
||||
switch state {
|
||||
case .synced, .syncing:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
26
BusinessCard/Services/SharedCardRecordNameCache.swift
Normal file
26
BusinessCard/Services/SharedCardRecordNameCache.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// Caches CloudKit record names for shared cards to avoid re-uploading when syncing to Watch.
|
||||
/// Populated when user shares via App Clip; used when syncing cards to Watch for App Clip QR generation.
|
||||
enum SharedCardRecordNameCache {
|
||||
private static let defaults = UserDefaults.standard
|
||||
private static let keyPrefix = "SharedCardRecordName."
|
||||
private static let expiryKeyPrefix = "SharedCardRecordNameExpiry."
|
||||
|
||||
/// Stores a record name for a card. Call when user shares via App Clip.
|
||||
static func set(recordName: String, cardID: UUID, expiresAt: Date) {
|
||||
defaults.set(recordName, forKey: keyPrefix + cardID.uuidString)
|
||||
defaults.set(expiresAt.timeIntervalSince1970, forKey: expiryKeyPrefix + cardID.uuidString)
|
||||
}
|
||||
|
||||
/// Returns a valid (non-expired) record name for the card, or nil if missing/expired.
|
||||
static func recordName(for cardID: UUID) -> String? {
|
||||
guard let name = defaults.string(forKey: keyPrefix + cardID.uuidString),
|
||||
let expiryInterval = defaults.object(forKey: expiryKeyPrefix + cardID.uuidString) as? TimeInterval else {
|
||||
return nil
|
||||
}
|
||||
let expiresAt = Date(timeIntervalSince1970: expiryInterval)
|
||||
guard Date() < expiresAt else { return nil }
|
||||
return name
|
||||
}
|
||||
}
|
||||
@ -40,22 +40,31 @@ final class WatchConnectivityService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
// In DEBUG, isWatchAppInstalled may be false even when running from Xcode
|
||||
// So we only require isPaired and isReachable or just try to send anyway
|
||||
#if DEBUG
|
||||
// Always send when paired. applicationContext persists and is delivered when Watch
|
||||
// launches (even if installed later). isWatchAppInstalled can lag after fresh install.
|
||||
guard session.isPaired else {
|
||||
Design.debugLog("WatchConnectivity: Watch not paired")
|
||||
return
|
||||
}
|
||||
Design.debugLog("WatchConnectivity: DEBUG mode - paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
|
||||
#else
|
||||
guard session.isPaired, session.isWatchAppInstalled else {
|
||||
Design.debugLog("WatchConnectivity: Watch not available (paired: \(session.isPaired), installed: \(session.isWatchAppInstalled))")
|
||||
return
|
||||
}
|
||||
#endif
|
||||
Design.debugLog("WatchConnectivity: paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
|
||||
|
||||
sendCardsToWatch(cards)
|
||||
Task {
|
||||
await ensureAppClipRecordNameForDefaultCard(cards)
|
||||
sendCardsToWatch(cards)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads default card to CloudKit if cache is empty, so Watch can show App Clip QR.
|
||||
private func ensureAppClipRecordNameForDefaultCard(_ cards: [BusinessCard]) async {
|
||||
guard let defaultCard = cards.first(where: { $0.isDefault }) ?? cards.first,
|
||||
SharedCardRecordNameCache.recordName(for: defaultCard.id) == nil else { return }
|
||||
do {
|
||||
let result = try await SharedCardCloudKitService().uploadSharedCard(defaultCard)
|
||||
SharedCardRecordNameCache.set(recordName: result.recordName, cardID: defaultCard.id, expiresAt: result.expiresAt)
|
||||
Design.debugLog("WatchConnectivity: Cached App Clip recordName for Watch")
|
||||
} catch {
|
||||
Design.debugLog("WatchConnectivity: Failed to upload for App Clip QR: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCardsToWatch(_ cards: [BusinessCard]) {
|
||||
@ -148,7 +157,14 @@ final class WatchConnectivityService: NSObject {
|
||||
|
||||
// Generate QR code image data on iOS (CoreImage not available on watchOS)
|
||||
let qrImageData = generateQRCodePNGData(from: vCardPayload)
|
||||
|
||||
|
||||
// App Clip QR: only when we have a cached recordName (from when user shared via App Clip)
|
||||
let appClipQRImageData: Data? = {
|
||||
guard let recordName = SharedCardRecordNameCache.recordName(for: card.id),
|
||||
let url = AppIdentifiers.appClipURL(recordName: recordName) else { return nil }
|
||||
return generateQRCodePNGData(from: url.absoluteString)
|
||||
}()
|
||||
|
||||
// Use fullName - the single source of truth for display names
|
||||
let syncDisplayName = card.fullName
|
||||
Design.debugLog("WatchConnectivity: Syncing card '\(syncDisplayName)'")
|
||||
@ -168,7 +184,8 @@ final class WatchConnectivityService: NSObject {
|
||||
linkedIn: linkedIn,
|
||||
twitter: twitter,
|
||||
instagram: instagram,
|
||||
qrCodeImageData: qrImageData
|
||||
qrCodeImageData: qrImageData,
|
||||
appClipQRCodeImageData: appClipQRImageData
|
||||
)
|
||||
}
|
||||
|
||||
@ -255,7 +272,7 @@ extension WatchConnectivityService: WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
|
||||
Task { @MainActor in
|
||||
Design.debugLog("WatchConnectivity: Session became inactive")
|
||||
|
||||
@ -28,7 +28,9 @@ final class AppClipShareState {
|
||||
defer { isUploading = false }
|
||||
|
||||
do {
|
||||
uploadResult = try await service.uploadSharedCard(card)
|
||||
let result = try await service.uploadSharedCard(card)
|
||||
uploadResult = result
|
||||
SharedCardRecordNameCache.set(recordName: result.recordName, cardID: card.id, expiresAt: result.expiresAt)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ final class AppState {
|
||||
let preferences: AppPreferencesStore
|
||||
let appSettings: AppSettingsStore
|
||||
let shareLinkService: ShareLinkProviding
|
||||
let cloudKitSyncMonitor: CloudKitSyncMonitor
|
||||
|
||||
var preferredColorScheme: ColorScheme? {
|
||||
preferences.preferredColorScheme
|
||||
@ -29,6 +30,7 @@ final class AppState {
|
||||
self.preferences = AppPreferencesStore()
|
||||
self.appSettings = AppSettingsStore(modelContext: modelContext)
|
||||
self.shareLinkService = ShareLinkService()
|
||||
self.cloudKitSyncMonitor = CloudKitSyncMonitor(isCloudKitEnabled: AppIdentifiers.isCloudKitSyncEnabled)
|
||||
|
||||
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
||||
Task {
|
||||
|
||||
@ -54,6 +54,8 @@ struct RootTabView: View {
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
updateOnboardingPresentation()
|
||||
// Re-sync cards to Watch when app becomes active (covers fresh Watch install)
|
||||
WatchConnectivityService.shared.syncCards(appState.cardStore.cards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,6 +41,12 @@ struct CardsHomeView: View {
|
||||
}
|
||||
.accessibilityHint(String.localized("Create a new business card"))
|
||||
}
|
||||
|
||||
if appState.cloudKitSyncMonitor.state != .disabled {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
SyncStatusBadge(monitor: appState.cloudKitSyncMonitor)
|
||||
}
|
||||
}
|
||||
|
||||
if cardStore.cards.count > 1 && cardStore.selectedCard != nil {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@ -98,6 +104,29 @@ struct CardsHomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync Status Badge
|
||||
|
||||
private struct SyncStatusBadge: View {
|
||||
let monitor: CloudKitSyncMonitor
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: monitor.statusIcon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(iconColor)
|
||||
.symbolEffect(.variableColor.iterative.reversing, isActive: monitor.state == .syncing)
|
||||
.accessibilityLabel(monitor.statusText)
|
||||
.accessibilityHint(String(localized: "iCloud sync status"))
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch monitor.state {
|
||||
case .synced: return AppStatus.success
|
||||
case .error: return AppStatus.error
|
||||
default: return Color.AppText.secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self)
|
||||
return CardsHomeView()
|
||||
|
||||
@ -9,7 +9,7 @@ struct CropGridLines: View {
|
||||
HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
Rectangle()
|
||||
.fill(Color.AppText.inverted.opacity(Design.Opacity.light))
|
||||
.fill(.white.opacity(Design.Opacity.light))
|
||||
.frame(width: Design.LineWidth.thin, height: cropSize.height)
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ struct CropGridLines: View {
|
||||
VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
Rectangle()
|
||||
.fill(Color.AppText.inverted.opacity(Design.Opacity.light))
|
||||
.fill(.white.opacity(Design.Opacity.light))
|
||||
.frame(width: cropSize.width, height: Design.LineWidth.thin)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ struct CropOverlay: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(Color.AppBackground.base.opacity(Design.Opacity.accent))
|
||||
.fill(Color.black.opacity(Design.Opacity.medium))
|
||||
|
||||
Rectangle()
|
||||
.fill(Color.clear)
|
||||
@ -19,7 +19,7 @@ struct CropOverlay: View {
|
||||
.allowsHitTesting(false)
|
||||
|
||||
Rectangle()
|
||||
.stroke(Color.AppText.inverted, lineWidth: Design.LineWidth.thin)
|
||||
.stroke(.white, lineWidth: Design.LineWidth.thin)
|
||||
.frame(width: cropSize.width, height: cropSize.height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
@ -88,8 +88,8 @@ struct PhotoCropperSheet: View {
|
||||
NavigationStack {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Dark background
|
||||
Color.AppBackground.base.ignoresSafeArea()
|
||||
// Always black background (consistent regardless of system theme)
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
// Image with gestures
|
||||
if let uiImage {
|
||||
@ -120,7 +120,8 @@ struct PhotoCropperSheet: View {
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.toolbarBackground(Color.black.opacity(Design.Opacity.heavy), for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String.localized("Cancel")) {
|
||||
@ -129,7 +130,7 @@ struct PhotoCropperSheet: View {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: Design.Spacing.xLarge) {
|
||||
@ -138,7 +139,7 @@ struct PhotoCropperSheet: View {
|
||||
rotate90Left()
|
||||
} label: {
|
||||
Image(systemName: "rotate.left")
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// Reset all transforms
|
||||
@ -146,7 +147,7 @@ struct PhotoCropperSheet: View {
|
||||
resetTransform()
|
||||
} label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// Aspect ratio picker (only for logo workflow)
|
||||
@ -155,7 +156,7 @@ struct PhotoCropperSheet: View {
|
||||
showingAspectRatioPicker = true
|
||||
} label: {
|
||||
Image(systemName: "aspectratio")
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,7 +165,7 @@ struct PhotoCropperSheet: View {
|
||||
rotate90Right()
|
||||
} label: {
|
||||
Image(systemName: "rotate.right")
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -173,7 +174,7 @@ struct PhotoCropperSheet: View {
|
||||
cropAndSave()
|
||||
}
|
||||
.bold()
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: - Settings Sections
|
||||
appearanceSection
|
||||
iCloudSyncSection
|
||||
cardsSection
|
||||
sharingSection
|
||||
contactsSection
|
||||
@ -89,6 +90,66 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iCloud Sync Section
|
||||
|
||||
private var iCloudSyncSection: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
SettingsSectionHeader(
|
||||
title: "iCloud",
|
||||
systemImage: "icloud",
|
||||
accentColor: AppThemeAccent.primary
|
||||
)
|
||||
|
||||
SettingsCard(
|
||||
backgroundColor: Color.AppBackground.elevated,
|
||||
borderColor: AppBorder.standard
|
||||
) {
|
||||
SettingsCardRow {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: appState.cloudKitSyncMonitor.statusIcon)
|
||||
.typography(.subheading)
|
||||
.foregroundStyle(syncIconColor)
|
||||
.symbolEffect(.variableColor.iterative.reversing, isActive: appState.cloudKitSyncMonitor.state == .syncing)
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
|
||||
Text("iCloud Sync")
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(Color.AppText.primary)
|
||||
|
||||
Text(appState.cloudKitSyncMonitor.statusText)
|
||||
.typography(.caption)
|
||||
.foregroundStyle(syncStatusColor)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var syncIconColor: Color {
|
||||
switch appState.cloudKitSyncMonitor.state {
|
||||
case .synced:
|
||||
return AppStatus.success
|
||||
case .error:
|
||||
return AppStatus.error
|
||||
case .syncing, .unknown:
|
||||
return Color.AppText.secondary
|
||||
case .disabled:
|
||||
return Color.AppText.tertiary
|
||||
}
|
||||
}
|
||||
|
||||
private var syncStatusColor: Color {
|
||||
switch appState.cloudKitSyncMonitor.state {
|
||||
case .error:
|
||||
return AppStatus.error
|
||||
default:
|
||||
return Color.AppText.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var versionFooter: some View {
|
||||
Text(settingsState.versionString)
|
||||
.typography(.caption)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user