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)