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 */; };
|
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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" : {
|
||||||
|
|
||||||
|
|||||||
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
|
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")
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user