diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj
index 20477cb..0fcea3d 100644
--- a/BusinessCard.xcodeproj/project.pbxproj
+++ b/BusinessCard.xcodeproj/project.pbxproj
@@ -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;
diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
index 9b29961..a8db312 100644
--- a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -17,7 +17,7 @@
BusinessCardWatch Watch App.xcscheme_^#shared#^_
orderHint
- 1
+ 3
SuppressBuildableAutocreation
diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift
index 82df317..04928f6 100644
--- a/BusinessCard/BusinessCardApp.swift
+++ b/BusinessCard/BusinessCardApp.swift
@@ -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
}
diff --git a/BusinessCard/Models/AppSettings.swift b/BusinessCard/Models/AppSettings.swift
index a27f5c5..927b07f 100644
--- a/BusinessCard/Models/AppSettings.swift
+++ b/BusinessCard/Models/AppSettings.swift
@@ -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(),
diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift
index 8c3c3c1..e13981d 100644
--- a/BusinessCard/Models/BusinessCard.swift
+++ b/BusinessCard/Models/BusinessCard.swift
@@ -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?
diff --git a/BusinessCard/Models/Contact.swift b/BusinessCard/Models/Contact.swift
index a3c12bc..ce324f2 100644
--- a/BusinessCard/Models/Contact.swift
+++ b/BusinessCard/Models/Contact.swift
@@ -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?
diff --git a/BusinessCard/Models/SyncableCard.swift b/BusinessCard/Models/SyncableCard.swift
index f02037d..ede1bbb 100644
--- a/BusinessCard/Models/SyncableCard.swift
+++ b/BusinessCard/Models/SyncableCard.swift
@@ -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?
}
diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings
index c3fa779..00b8d73 100644
--- a/BusinessCard/Resources/Localizable.xcstrings
+++ b/BusinessCard/Resources/Localizable.xcstrings
@@ -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" : {
diff --git a/BusinessCard/Services/CloudKitSyncMonitor.swift b/BusinessCard/Services/CloudKitSyncMonitor.swift
new file mode 100644
index 0000000..5bc13db
--- /dev/null
+++ b/BusinessCard/Services/CloudKitSyncMonitor.swift
@@ -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
+ }
+ }
+}
diff --git a/BusinessCard/Services/SharedCardRecordNameCache.swift b/BusinessCard/Services/SharedCardRecordNameCache.swift
new file mode 100644
index 0000000..5261055
--- /dev/null
+++ b/BusinessCard/Services/SharedCardRecordNameCache.swift
@@ -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
+ }
+}
diff --git a/BusinessCard/Services/WatchConnectivityService.swift b/BusinessCard/Services/WatchConnectivityService.swift
index 11ec411..342a283 100644
--- a/BusinessCard/Services/WatchConnectivityService.swift
+++ b/BusinessCard/Services/WatchConnectivityService.swift
@@ -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")
diff --git a/BusinessCard/State/AppClipShareState.swift b/BusinessCard/State/AppClipShareState.swift
index 66e1f07..edb402f 100644
--- a/BusinessCard/State/AppClipShareState.swift
+++ b/BusinessCard/State/AppClipShareState.swift
@@ -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
}
diff --git a/BusinessCard/State/AppState.swift b/BusinessCard/State/AppState.swift
index a528ed6..e2219e4 100644
--- a/BusinessCard/State/AppState.swift
+++ b/BusinessCard/State/AppState.swift
@@ -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 {
diff --git a/BusinessCard/Views/Features/AppShell/RootTabView.swift b/BusinessCard/Views/Features/AppShell/RootTabView.swift
index f9968ac..e85e001 100644
--- a/BusinessCard/Views/Features/AppShell/RootTabView.swift
+++ b/BusinessCard/Views/Features/AppShell/RootTabView.swift
@@ -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)
}
}
}
diff --git a/BusinessCard/Views/Features/Cards/CardsHomeView.swift b/BusinessCard/Views/Features/Cards/CardsHomeView.swift
index d996a76..5292298 100644
--- a/BusinessCard/Views/Features/Cards/CardsHomeView.swift
+++ b/BusinessCard/Views/Features/Cards/CardsHomeView.swift
@@ -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()
diff --git a/BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift b/BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift
index f4b89ae..f4288a8 100644
--- a/BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift
+++ b/BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift
@@ -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)
}
}
diff --git a/BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift b/BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift
index d1a4d14..26ff4cb 100644
--- a/BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift
+++ b/BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift
@@ -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)
}
diff --git a/BusinessCard/Views/Features/Cards/Sheets/PhotoCropperSheet.swift b/BusinessCard/Views/Features/Cards/Sheets/PhotoCropperSheet.swift
index 37c96c2..e644624 100644
--- a/BusinessCard/Views/Features/Cards/Sheets/PhotoCropperSheet.swift
+++ b/BusinessCard/Views/Features/Cards/Sheets/PhotoCropperSheet.swift
@@ -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)
}
}
}
diff --git a/BusinessCard/Views/Features/Settings/SettingsView.swift b/BusinessCard/Views/Features/Settings/SettingsView.swift
index c4943b8..7845263 100644
--- a/BusinessCard/Views/Features/Settings/SettingsView.swift
+++ b/BusinessCard/Views/Features/Settings/SettingsView.swift
@@ -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)