- Design system: spacing, typography, colors, animations, opacity, shadows - Protocol-based color theming (AppColorTheme) - Settings UI components: toggles, pickers, selection indicators - Sound and haptic feedback manager (generic AppSound protocol) - Onboarding state management - Cloud sync manager (PersistableData protocol) - Visual effects: ConfettiView, PulsingModifier - Debug utilities: DebugBorderModifier - Device information utilities (cross-platform) - Unit tests for design constants
416 lines
13 KiB
Swift
416 lines
13 KiB
Swift
//
|
|
// CloudSyncManager.swift
|
|
// Bedrock
|
|
//
|
|
// Generic iCloud sync manager for app data.
|
|
// Uses NSUbiquitousKeyValueStore for lightweight cross-device sync.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - PersistableData Protocol
|
|
|
|
/// Protocol for data that can be persisted locally and synced to iCloud.
|
|
///
|
|
/// Conform to this protocol to enable automatic iCloud sync for your app's data.
|
|
///
|
|
/// ## Example
|
|
///
|
|
/// ```swift
|
|
/// struct GameStats: PersistableData {
|
|
/// static var dataIdentifier = "gameStats"
|
|
/// static var empty = GameStats()
|
|
///
|
|
/// var gamesPlayed: Int = 0
|
|
/// var lastModified: Date = .now
|
|
///
|
|
/// var syncPriority: Int { gamesPlayed } // Higher = more progress
|
|
/// }
|
|
/// ```
|
|
public protocol PersistableData: Codable, Sendable {
|
|
/// Unique identifier for this data type (e.g., "userProfile", "gameStats").
|
|
/// Used as the storage key.
|
|
static var dataIdentifier: String { get }
|
|
|
|
/// Priority value for conflict resolution.
|
|
/// When cloud and local data conflict, the version with higher priority wins.
|
|
/// Typically represents "progress" (e.g., games played, items collected).
|
|
var syncPriority: Int { get }
|
|
|
|
/// Last time this data was modified.
|
|
var lastModified: Date { get set }
|
|
|
|
/// Creates empty/default data.
|
|
static var empty: Self { get }
|
|
}
|
|
|
|
// MARK: - CloudSyncManager
|
|
|
|
/// Manages data persistence to local storage and iCloud.
|
|
///
|
|
/// Features:
|
|
/// - Automatic sync with iCloud using `NSUbiquitousKeyValueStore`
|
|
/// - Local fallback using `UserDefaults`
|
|
/// - Conflict resolution based on `syncPriority`
|
|
/// - Change notifications when data updates from another device
|
|
///
|
|
/// ## Usage
|
|
///
|
|
/// ```swift
|
|
/// @Observable
|
|
/// class AppState {
|
|
/// let syncManager = CloudSyncManager<UserData>()
|
|
///
|
|
/// var userData: UserData {
|
|
/// syncManager.data
|
|
/// }
|
|
///
|
|
/// func saveProgress() {
|
|
/// syncManager.update { data in
|
|
/// data.level += 1
|
|
/// }
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
@MainActor
|
|
@Observable
|
|
public final class CloudSyncManager<T: PersistableData> {
|
|
|
|
// MARK: - Properties
|
|
|
|
/// The current data.
|
|
public private(set) var data: T
|
|
|
|
/// Whether iCloud sync is available (user signed in to iCloud).
|
|
public var iCloudAvailable: Bool {
|
|
let token = FileManager.default.ubiquityIdentityToken
|
|
let available = token != nil
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud available = \(available)")
|
|
return available
|
|
}
|
|
|
|
/// Whether iCloud sync is enabled by the user.
|
|
public var iCloudEnabled: Bool {
|
|
get { UserDefaults.standard.bool(forKey: iCloudEnabledKey) }
|
|
set {
|
|
UserDefaults.standard.set(newValue, forKey: iCloudEnabledKey)
|
|
if newValue { sync() }
|
|
}
|
|
}
|
|
|
|
/// Last successful sync date.
|
|
public private(set) var lastSyncDate: Date?
|
|
|
|
/// Whether a sync is currently in progress.
|
|
public private(set) var isSyncing: Bool = false
|
|
|
|
/// Human-readable sync status.
|
|
public private(set) var syncStatus: String = ""
|
|
|
|
/// Whether initial iCloud sync has completed.
|
|
public private(set) var hasCompletedInitialSync: Bool = false
|
|
|
|
/// Callback when data is received from iCloud.
|
|
public var onCloudDataReceived: ((T) -> Void)?
|
|
|
|
// MARK: - Private Properties
|
|
|
|
private var _iCloudStore: NSUbiquitousKeyValueStore?
|
|
private var _iCloudStoreInitialized = false
|
|
|
|
private var iCloudStore: NSUbiquitousKeyValueStore? {
|
|
if _iCloudStoreInitialized {
|
|
return _iCloudStore
|
|
}
|
|
|
|
_iCloudStoreInitialized = true
|
|
|
|
guard iCloudAvailable else {
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud not available")
|
|
return nil
|
|
}
|
|
|
|
_iCloudStore = NSUbiquitousKeyValueStore.default
|
|
return _iCloudStore
|
|
}
|
|
|
|
private let encoder = JSONEncoder()
|
|
private let decoder = JSONDecoder()
|
|
|
|
private var localKey: String { "\(T.dataIdentifier).data" }
|
|
private var cloudKey: String { "\(T.dataIdentifier).data" }
|
|
private var syncDateKey: String { "\(T.dataIdentifier).lastSync" }
|
|
private var iCloudEnabledKey: String { "\(T.dataIdentifier).iCloudEnabled" }
|
|
|
|
// MARK: - Initialization
|
|
|
|
public init() {
|
|
self.data = T.empty
|
|
|
|
// Default iCloud enabled to true
|
|
if UserDefaults.standard.object(forKey: iCloudEnabledKey) == nil {
|
|
UserDefaults.standard.set(true, forKey: iCloudEnabledKey)
|
|
}
|
|
|
|
// Register for iCloud changes
|
|
if iCloudAvailable, let store = iCloudStore {
|
|
NotificationCenter.default.addObserver(
|
|
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
|
object: store,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard let userInfo = notification.userInfo,
|
|
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,
|
|
let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else {
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
self?.handleCloudChange(reason: reason, changedKeys: changedKeys)
|
|
}
|
|
}
|
|
|
|
// Trigger iCloud sync
|
|
if iCloudEnabled {
|
|
store.synchronize()
|
|
}
|
|
}
|
|
|
|
// Load data
|
|
self.data = load()
|
|
|
|
// On fresh install, wait for iCloud data
|
|
if data.syncPriority == 0 && iCloudAvailable && iCloudEnabled {
|
|
scheduleDelayedCloudCheck()
|
|
}
|
|
}
|
|
|
|
// MARK: - Save
|
|
|
|
/// Saves the current data locally and to iCloud.
|
|
public func save(_ newData: T) {
|
|
var dataToSave = newData
|
|
dataToSave.lastModified = Date()
|
|
self.data = dataToSave
|
|
|
|
guard let encoded = try? encoder.encode(dataToSave) else {
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Failed to encode data")
|
|
return
|
|
}
|
|
|
|
// Save locally
|
|
UserDefaults.standard.set(encoded, forKey: localKey)
|
|
|
|
// Save to iCloud
|
|
if iCloudAvailable && iCloudEnabled, let store = iCloudStore {
|
|
store.set(encoded, forKey: cloudKey)
|
|
store.set(Date(), forKey: syncDateKey)
|
|
store.synchronize()
|
|
lastSyncDate = Date()
|
|
syncStatus = "Synced"
|
|
}
|
|
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Saved (priority: \(dataToSave.syncPriority))")
|
|
}
|
|
|
|
/// Updates and saves data in one call.
|
|
public func update(_ transform: (inout T) -> Void) {
|
|
var updated = data
|
|
transform(&updated)
|
|
save(updated)
|
|
}
|
|
|
|
// MARK: - Load
|
|
|
|
/// Loads data, preferring iCloud if it has higher priority.
|
|
public func load() -> T {
|
|
let localData = loadLocal()
|
|
let cloudData = loadCloud()
|
|
|
|
let finalData: T
|
|
|
|
switch (localData, cloudData) {
|
|
case (nil, nil):
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: No saved data, using empty")
|
|
finalData = T.empty
|
|
|
|
case (let local?, nil):
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using local data")
|
|
finalData = local
|
|
|
|
case (nil, let cloud?):
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using iCloud data")
|
|
finalData = cloud
|
|
|
|
case (let local?, let cloud?):
|
|
// Use whichever has higher priority (more progress)
|
|
if cloud.syncPriority > local.syncPriority {
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using iCloud (priority \(cloud.syncPriority) > \(local.syncPriority))")
|
|
finalData = cloud
|
|
// Update local with cloud data
|
|
if let encoded = try? encoder.encode(cloud) {
|
|
UserDefaults.standard.set(encoded, forKey: localKey)
|
|
}
|
|
} else if local.lastModified > cloud.lastModified {
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using local (newer)")
|
|
finalData = local
|
|
} else {
|
|
finalData = local
|
|
}
|
|
}
|
|
|
|
return finalData
|
|
}
|
|
|
|
private func loadLocal() -> T? {
|
|
guard let data = UserDefaults.standard.data(forKey: localKey),
|
|
let decoded = try? decoder.decode(T.self, from: data) else {
|
|
return nil
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
private func loadCloud() -> T? {
|
|
guard iCloudAvailable && iCloudEnabled,
|
|
let store = iCloudStore,
|
|
let data = store.data(forKey: cloudKey),
|
|
let decoded = try? decoder.decode(T.self, from: data) else {
|
|
return nil
|
|
}
|
|
|
|
if let syncDate = store.object(forKey: syncDateKey) as? Date {
|
|
lastSyncDate = syncDate
|
|
}
|
|
|
|
return decoded
|
|
}
|
|
|
|
// MARK: - Sync
|
|
|
|
/// Forces a sync with iCloud.
|
|
public func sync() {
|
|
guard iCloudAvailable && iCloudEnabled else {
|
|
syncStatus = iCloudAvailable ? "Sync disabled" : "iCloud unavailable"
|
|
return
|
|
}
|
|
|
|
guard let store = iCloudStore else {
|
|
syncStatus = "iCloud unavailable"
|
|
return
|
|
}
|
|
|
|
isSyncing = true
|
|
syncStatus = "Syncing..."
|
|
|
|
store.synchronize()
|
|
|
|
// Reload to get any changes
|
|
let latestData = load()
|
|
if latestData.syncPriority != data.syncPriority {
|
|
data = latestData
|
|
onCloudDataReceived?(latestData)
|
|
}
|
|
|
|
isSyncing = false
|
|
lastSyncDate = Date()
|
|
syncStatus = "Synced"
|
|
}
|
|
|
|
// MARK: - Reset
|
|
|
|
/// Clears all saved data locally and from iCloud.
|
|
public func reset() {
|
|
UserDefaults.standard.removeObject(forKey: localKey)
|
|
|
|
if iCloudAvailable, let store = iCloudStore {
|
|
store.removeObject(forKey: cloudKey)
|
|
store.removeObject(forKey: syncDateKey)
|
|
store.synchronize()
|
|
}
|
|
|
|
data = T.empty
|
|
syncStatus = "Data cleared"
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: All data cleared")
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func scheduleDelayedCloudCheck() {
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Scheduling delayed cloud check...")
|
|
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .seconds(2))
|
|
|
|
guard let store = iCloudStore else {
|
|
hasCompletedInitialSync = true
|
|
return
|
|
}
|
|
|
|
_ = store.synchronize()
|
|
|
|
if let cloudData = loadCloud(), cloudData.syncPriority > data.syncPriority {
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Cloud has more data, updating...")
|
|
data = cloudData
|
|
onCloudDataReceived?(cloudData)
|
|
|
|
if let encoded = try? encoder.encode(cloudData) {
|
|
UserDefaults.standard.set(encoded, forKey: localKey)
|
|
}
|
|
|
|
NotificationCenter.default.post(
|
|
name: .persistedDataDidChange,
|
|
object: nil,
|
|
userInfo: ["dataIdentifier": T.dataIdentifier]
|
|
)
|
|
}
|
|
|
|
hasCompletedInitialSync = true
|
|
}
|
|
}
|
|
|
|
private func handleCloudChange(reason: Int, changedKeys: [String]) {
|
|
guard changedKeys.contains(cloudKey) else { return }
|
|
|
|
switch reason {
|
|
case NSUbiquitousKeyValueStoreServerChange,
|
|
NSUbiquitousKeyValueStoreInitialSyncChange:
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Data changed from another device")
|
|
syncStatus = "Received update"
|
|
|
|
if let cloudData = loadCloud(), cloudData.syncPriority > data.syncPriority {
|
|
data = cloudData
|
|
onCloudDataReceived?(cloudData)
|
|
|
|
if let encoded = try? encoder.encode(cloudData) {
|
|
UserDefaults.standard.set(encoded, forKey: localKey)
|
|
}
|
|
|
|
NotificationCenter.default.post(
|
|
name: .persistedDataDidChange,
|
|
object: nil,
|
|
userInfo: ["dataIdentifier": T.dataIdentifier]
|
|
)
|
|
}
|
|
|
|
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud quota exceeded")
|
|
syncStatus = "Storage full"
|
|
|
|
case NSUbiquitousKeyValueStoreAccountChange:
|
|
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud account changed")
|
|
syncStatus = "Account changed"
|
|
data = load()
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
public extension Notification.Name {
|
|
/// Posted when persisted data changes from iCloud.
|
|
static let persistedDataDidChange = Notification.Name("persistedDataDidChange")
|
|
}
|