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

This commit is contained in:
Matt Bruce 2026-02-14 16:06:13 -06:00
parent f4ea7c0ed7
commit 80439171bb
19 changed files with 407 additions and 81 deletions

View File

@ -10,7 +10,7 @@
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; }; EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; };
EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69E9262F3D4B5700592220 /* Bedrock */; }; EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69E9262F3D4B5700592220 /* Bedrock */; };
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* 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, ); }; }; EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -806,7 +806,7 @@
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch; INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -840,7 +840,7 @@
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = BusinessCardWatch; INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -875,7 +875,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCardClip/Info.plist; INFOPLIST_FILE = BusinessCardClip/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard; INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground; INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
@ -887,7 +887,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -914,7 +914,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCardClip/Info.plist; INFOPLIST_FILE = BusinessCardClip/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard; INFOPLIST_KEY_CFBundleDisplayName = "Business Card";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground; INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
@ -926,7 +926,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;

View File

@ -17,7 +17,7 @@
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key> <key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>3</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>

View File

@ -94,7 +94,7 @@ struct BusinessCardApp: App {
self.modelContainer = container self.modelContainer = container
let context = container.mainContext let context = container.mainContext
self._appState = State(initialValue: AppState(modelContext: context)) self._appState = State(initialValue: AppState(modelContext: context))
// Activate WatchConnectivity session // Activate WatchConnectivity session
_ = WatchConnectivityService.shared _ = WatchConnectivityService.shared
} }

View File

@ -3,11 +3,11 @@ import SwiftData
@Model @Model
final class AppSettings { final class AppSettings {
var id: UUID var id: UUID = UUID()
var preferredShareActionRawValue: String var preferredShareActionRawValue: String = "shareSheet"
var defaultFollowUpPresetRawValue: String var defaultFollowUpPresetRawValue: String = "none"
var createdAt: Date var createdAt: Date = Date()
var updatedAt: Date var updatedAt: Date = Date()
init( init(
id: UUID = UUID(), id: UUID = UUID(),

View File

@ -4,31 +4,31 @@ import SwiftUI
@Model @Model
final class BusinessCard { final class BusinessCard {
var id: UUID var id: UUID = UUID()
var role: String var role: String = ""
var company: String var company: String = ""
var label: String var label: String = "Work"
var isDefault: Bool var isDefault: Bool = false
var themeName: String var themeName: String = "Coral"
var layoutStyleRawValue: String var layoutStyleRawValue: String = "stacked"
var headerLayoutRawValue: String var headerLayoutRawValue: String = "profileBanner"
var avatarSystemName: String var avatarSystemName: String = "person.crop.circle"
var createdAt: Date var createdAt: Date = Date()
var updatedAt: Date var updatedAt: Date = Date()
// Enhanced profile fields // Enhanced profile fields
var prefix: String var prefix: String = ""
var firstName: String var firstName: String = ""
var middleName: String var middleName: String = ""
var lastName: String var lastName: String = ""
var suffix: String var suffix: String = ""
var maidenName: String var maidenName: String = ""
var preferredName: String var preferredName: String = ""
var pronouns: String var pronouns: String = ""
var department: String var department: String = ""
var headline: String var headline: String = ""
var bio: String var bio: String = ""
var accreditations: String var accreditations: String = ""
// Profile photo stored as Data (JPEG) // Profile photo stored as Data (JPEG)
@Attribute(.externalStorage) var photoData: Data? @Attribute(.externalStorage) var photoData: Data?

View File

@ -3,27 +3,27 @@ import SwiftData
@Model @Model
final class Contact { final class Contact {
var id: UUID var id: UUID = UUID()
var name: String var name: String = ""
var role: String var role: String = ""
var company: String var company: String = ""
var avatarSystemName: String var avatarSystemName: String = "person.crop.circle"
var lastSharedDate: Date var lastSharedDate: Date = Date()
var cardLabel: String var cardLabel: String = "Work"
// Contact annotations // Contact annotations
var notes: String var notes: String = ""
var tags: String // Comma-separated tags var tags: String = "" // Comma-separated tags
var followUpDate: Date? var followUpDate: Date?
var email: String // Legacy single email (kept for migration/fallback) var email: String = "" // Legacy single email (kept for migration/fallback)
var phone: String // Legacy single phone (kept for migration/fallback) var phone: String = "" // Legacy single phone (kept for migration/fallback)
// Multiple contact fields (phones, emails, links with labels) // Multiple contact fields (phones, emails, links with labels)
@Relationship(deleteRule: .cascade, inverse: \ContactField.contact) @Relationship(deleteRule: .cascade, inverse: \ContactField.contact)
var contactFields: [ContactField]? var contactFields: [ContactField]?
// If this is a received card (scanned from someone else) // If this is a received card (scanned from someone else)
var isReceivedCard: Bool var isReceivedCard: Bool = false
// Profile photo // Profile photo
@Attribute(.externalStorage) var photoData: Data? @Attribute(.externalStorage) var photoData: Data?

View File

@ -18,4 +18,6 @@ struct SyncableCard: Codable, Identifiable {
var instagram: String var instagram: String
/// Pre-generated QR code PNG data (CoreImage not available on watchOS). /// Pre-generated QR code PNG data (CoreImage not available on watchOS).
var qrCodeImageData: Data? var qrCodeImageData: Data?
/// Pre-generated App Clip URL QR code PNG data.
var appClipQRCodeImageData: Data?
} }

View File

@ -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." : { "Choose a card in the My Cards tab to start sharing." : {
}, },
@ -408,6 +412,14 @@
}, },
"Home" : { "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" : { "Images & layout" : {
"localizations" : { "localizations" : {
@ -442,6 +454,10 @@
}, },
"Links" : { "Links" : {
},
"Local only" : {
"comment" : "Status text indicating that the app is using only local storage (CloudKit sync is disabled).",
"isCommentAutoGenerated" : true
}, },
"Logo" : { "Logo" : {
@ -825,6 +841,18 @@
}, },
"Support & Funding" : { "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" : { "Tags" : {

View 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
}
}
}

View 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
}
}

View File

@ -40,22 +40,31 @@ final class WatchConnectivityService: NSObject {
return return
} }
// In DEBUG, isWatchAppInstalled may be false even when running from Xcode // Always send when paired. applicationContext persists and is delivered when Watch
// So we only require isPaired and isReachable or just try to send anyway // launches (even if installed later). isWatchAppInstalled can lag after fresh install.
#if DEBUG
guard session.isPaired else { guard session.isPaired else {
Design.debugLog("WatchConnectivity: Watch not paired") Design.debugLog("WatchConnectivity: Watch not paired")
return return
} }
Design.debugLog("WatchConnectivity: DEBUG mode - paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)") Design.debugLog("WatchConnectivity: 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
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]) { 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) // Generate QR code image data on iOS (CoreImage not available on watchOS)
let qrImageData = generateQRCodePNGData(from: vCardPayload) 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 // Use fullName - the single source of truth for display names
let syncDisplayName = card.fullName let syncDisplayName = card.fullName
Design.debugLog("WatchConnectivity: Syncing card '\(syncDisplayName)'") Design.debugLog("WatchConnectivity: Syncing card '\(syncDisplayName)'")
@ -168,7 +184,8 @@ final class WatchConnectivityService: NSObject {
linkedIn: linkedIn, linkedIn: linkedIn,
twitter: twitter, twitter: twitter,
instagram: instagram, instagram: instagram,
qrCodeImageData: qrImageData qrCodeImageData: qrImageData,
appClipQRCodeImageData: appClipQRImageData
) )
} }
@ -255,7 +272,7 @@ extension WatchConnectivityService: WCSessionDelegate {
} }
} }
} }
nonisolated func sessionDidBecomeInactive(_ session: WCSession) { nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
Task { @MainActor in Task { @MainActor in
Design.debugLog("WatchConnectivity: Session became inactive") Design.debugLog("WatchConnectivity: Session became inactive")

View File

@ -28,7 +28,9 @@ final class AppClipShareState {
defer { isUploading = false } defer { isUploading = false }
do { 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 { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
} }

View File

@ -13,6 +13,7 @@ final class AppState {
let preferences: AppPreferencesStore let preferences: AppPreferencesStore
let appSettings: AppSettingsStore let appSettings: AppSettingsStore
let shareLinkService: ShareLinkProviding let shareLinkService: ShareLinkProviding
let cloudKitSyncMonitor: CloudKitSyncMonitor
var preferredColorScheme: ColorScheme? { var preferredColorScheme: ColorScheme? {
preferences.preferredColorScheme preferences.preferredColorScheme
@ -29,6 +30,7 @@ final class AppState {
self.preferences = AppPreferencesStore() self.preferences = AppPreferencesStore()
self.appSettings = AppSettingsStore(modelContext: modelContext) self.appSettings = AppSettingsStore(modelContext: modelContext)
self.shareLinkService = ShareLinkService() self.shareLinkService = ShareLinkService()
self.cloudKitSyncMonitor = CloudKitSyncMonitor(isCloudKitEnabled: AppIdentifiers.isCloudKitSyncEnabled)
// Clean up expired shared cards on launch (best-effort, non-blocking) // Clean up expired shared cards on launch (best-effort, non-blocking)
Task { Task {

View File

@ -54,6 +54,8 @@ struct RootTabView: View {
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { if newPhase == .active {
updateOnboardingPresentation() updateOnboardingPresentation()
// Re-sync cards to Watch when app becomes active (covers fresh Watch install)
WatchConnectivityService.shared.syncCards(appState.cardStore.cards)
} }
} }
} }

View File

@ -41,6 +41,12 @@ struct CardsHomeView: View {
} }
.accessibilityHint(String.localized("Create a new business card")) .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 { if cardStore.cards.count > 1 && cardStore.selectedCard != nil {
ToolbarItem(placement: .topBarTrailing) { 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 { #Preview {
let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self) let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self)
return CardsHomeView() return CardsHomeView()

View File

@ -9,7 +9,7 @@ struct CropGridLines: View {
HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) { HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) {
ForEach(0..<2, id: \.self) { _ in ForEach(0..<2, id: \.self) { _ in
Rectangle() Rectangle()
.fill(Color.AppText.inverted.opacity(Design.Opacity.light)) .fill(.white.opacity(Design.Opacity.light))
.frame(width: Design.LineWidth.thin, height: cropSize.height) .frame(width: Design.LineWidth.thin, height: cropSize.height)
} }
} }
@ -17,7 +17,7 @@ struct CropGridLines: View {
VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) { VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) {
ForEach(0..<2, id: \.self) { _ in ForEach(0..<2, id: \.self) { _ in
Rectangle() Rectangle()
.fill(Color.AppText.inverted.opacity(Design.Opacity.light)) .fill(.white.opacity(Design.Opacity.light))
.frame(width: cropSize.width, height: Design.LineWidth.thin) .frame(width: cropSize.width, height: Design.LineWidth.thin)
} }
} }

View File

@ -8,7 +8,7 @@ struct CropOverlay: View {
var body: some View { var body: some View {
ZStack { ZStack {
Rectangle() Rectangle()
.fill(Color.AppBackground.base.opacity(Design.Opacity.accent)) .fill(Color.black.opacity(Design.Opacity.medium))
Rectangle() Rectangle()
.fill(Color.clear) .fill(Color.clear)
@ -19,7 +19,7 @@ struct CropOverlay: View {
.allowsHitTesting(false) .allowsHitTesting(false)
Rectangle() Rectangle()
.stroke(Color.AppText.inverted, lineWidth: Design.LineWidth.thin) .stroke(.white, lineWidth: Design.LineWidth.thin)
.frame(width: cropSize.width, height: cropSize.height) .frame(width: cropSize.width, height: cropSize.height)
.allowsHitTesting(false) .allowsHitTesting(false)
} }

View File

@ -88,8 +88,8 @@ struct PhotoCropperSheet: View {
NavigationStack { NavigationStack {
GeometryReader { geometry in GeometryReader { geometry in
ZStack { ZStack {
// Dark background // Always black background (consistent regardless of system theme)
Color.AppBackground.base.ignoresSafeArea() Color.black.ignoresSafeArea()
// Image with gestures // Image with gestures
if let uiImage { if let uiImage {
@ -120,7 +120,8 @@ struct PhotoCropperSheet: View {
} }
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar) .toolbarBackground(Color.black.opacity(Design.Opacity.heavy), for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) { Button(String.localized("Cancel")) {
@ -129,7 +130,7 @@ struct PhotoCropperSheet: View {
dismiss() dismiss()
} }
} }
.foregroundStyle(Color.AppText.inverted) .foregroundStyle(.white)
} }
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
HStack(spacing: Design.Spacing.xLarge) { HStack(spacing: Design.Spacing.xLarge) {
@ -138,7 +139,7 @@ struct PhotoCropperSheet: View {
rotate90Left() rotate90Left()
} label: { } label: {
Image(systemName: "rotate.left") Image(systemName: "rotate.left")
.foregroundStyle(Color.AppText.inverted) .foregroundStyle(.white)
} }
// Reset all transforms // Reset all transforms
@ -146,7 +147,7 @@ struct PhotoCropperSheet: View {
resetTransform() resetTransform()
} label: { } label: {
Image(systemName: "arrow.counterclockwise") Image(systemName: "arrow.counterclockwise")
.foregroundStyle(Color.AppText.inverted) .foregroundStyle(.white)
} }
// Aspect ratio picker (only for logo workflow) // Aspect ratio picker (only for logo workflow)
@ -155,7 +156,7 @@ struct PhotoCropperSheet: View {
showingAspectRatioPicker = true showingAspectRatioPicker = true
} label: { } label: {
Image(systemName: "aspectratio") Image(systemName: "aspectratio")
.foregroundStyle(Color.AppText.inverted) .foregroundStyle(.white)
} }
} }
@ -164,7 +165,7 @@ struct PhotoCropperSheet: View {
rotate90Right() rotate90Right()
} label: { } label: {
Image(systemName: "rotate.right") Image(systemName: "rotate.right")
.foregroundStyle(Color.AppText.inverted) .foregroundStyle(.white)
} }
} }
} }
@ -173,7 +174,7 @@ struct PhotoCropperSheet: View {
cropAndSave() cropAndSave()
} }
.bold() .bold()
.foregroundStyle(Color.AppText.inverted) .foregroundStyle(.white)
} }
} }
} }

View File

@ -24,6 +24,7 @@ struct SettingsView: View {
// MARK: - Settings Sections // MARK: - Settings Sections
appearanceSection appearanceSection
iCloudSyncSection
cardsSection cardsSection
sharingSection sharingSection
contactsSection 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 { private var versionFooter: some View {
Text(settingsState.versionString) Text(settingsState.versionString)
.typography(.caption) .typography(.caption)