SelfieCam/SelfieCam/Features/Settings/ViewModels/SettingsViewModel.swift

178 lines
6.0 KiB
Swift

//
// SettingsViewModel.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
import Bedrock
import MijickCamera
// MARK: - Settings ViewModel
/// Observable settings view model with iCloud sync across all devices.
/// Uses Bedrock's CloudSyncManager for automatic synchronization.
/// Premium features are automatically reset to defaults when user doesn't have premium.
///
/// Extensions:
/// - `SettingsViewModel+RingLight.swift` - Ring light settings
/// - `SettingsViewModel+Camera.swift` - Camera settings (flash, HDR, timer, etc.)
/// - `SettingsViewModel+Premium.swift` - Premium/debug toggle and reset logic
/// - `SettingsViewModel+CloudSync.swift` - iCloud sync conformance
@MainActor
@Observable
final class SettingsViewModel: RingLightConfigurable {
// MARK: - Ring Size Limits
/// Minimum ring border size in points
static let minRingSize: CGFloat = 40
/// Maximum ring border size in points
static let maxRingSize: CGFloat = 120
/// Default ring border size
static let defaultRingSize: CGFloat = 40
// MARK: - Premium Constants
/// Default free color ID for non-premium users
static let defaultFreeColorId = "pureWhite"
/// Premium color IDs that require subscription
static let premiumColorIds: Set<String> = ["iceBlue", "softPink", "warmAmber", "coolLavender", RingLightColor.customId]
/// Premium timer options that require subscription
static let premiumTimerOptions: Set<TimerOption> = [.five, .ten]
/// Premium photo quality options that require subscription
static let premiumPhotoQualities: Set<PhotoQuality> = [.high]
// MARK: - Internal Dependencies
/// Premium manager for checking subscription status
@ObservationIgnored let premiumManager = PremiumManager()
/// Manages iCloud sync for settings across all devices
let cloudSync = CloudSyncManager<SyncedSettings>()
/// Debounce task for slider values
var debounceTask: Task<Void, Never>?
/// Debounce delay for continuous slider updates (in seconds)
private static let debounceDelay: Duration = .milliseconds(300)
// MARK: - Cached Values (for immediate UI updates)
/// Cached ring size for immediate UI updates (before debounced save)
var _cachedRingSize: CGFloat?
/// Cached light color ID for immediate UI updates
var _cachedLightColorId: String?
/// Cached custom color for immediate UI updates
var _cachedCustomColor: Color?
// MARK: - Premium Status
/// Refresh token that changes when premium status needs to be re-evaluated
/// Incrementing this forces SwiftUI to re-compute isPremiumUnlocked
private var premiumRefreshToken: Int = 0
/// Whether the user has premium access (computed from PremiumManager)
/// This always reads the current status from RevenueCat's cached customer info
var isPremiumUnlocked: Bool {
// Access refresh token to create observation dependency
_ = premiumRefreshToken
return premiumManager.isPremiumUnlocked
}
/// Force SwiftUI to re-evaluate premium status
/// Call this after a purchase completes or when returning from paywall
func refreshPremiumStatus() {
premiumRefreshToken += 1
}
// MARK: - Display Settings
/// Whether the camera preview is flipped to show a true mirror (PREMIUM)
var isMirrorFlipped: Bool {
get { PremiumGate.get(cloudSync.data.isMirrorFlipped, default: false, isPremium: isPremiumUnlocked) }
set {
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
updateSettings { $0.isMirrorFlipped = newValue }
}
}
/// Whether skin smoothing filter is enabled (PREMIUM)
var isSkinSmoothingEnabled: Bool {
get { PremiumGate.get(cloudSync.data.isSkinSmoothingEnabled, default: false, isPremium: isPremiumUnlocked) }
set {
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
updateSettings { $0.isSkinSmoothingEnabled = newValue }
}
}
/// Whether the grid overlay is visible
var isGridVisible: Bool {
get { cloudSync.data.isGridVisible }
set { updateSettings { $0.isGridVisible = newValue } }
}
/// Current camera zoom factor
var currentZoomFactor: Double {
get { cloudSync.data.currentZoomFactor }
set { updateSettings { $0.currentZoomFactor = newValue } }
}
// MARK: - Capture Settings
/// Whether captures are auto-saved to Photo Library
var isAutoSaveEnabled: Bool {
get { cloudSync.data.isAutoSaveEnabled }
set { updateSettings { $0.isAutoSaveEnabled = newValue } }
}
// MARK: - Initialization
init() {
// CloudSyncManager handles syncing automatically
// Premium status is always computed from premiumManager.isPremiumUnlocked
}
// MARK: - Internal Methods
/// Updates settings and saves to cloud immediately
func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
// Since we are using @Observable, we need to ensure the property access is tracked
// The cloudSync.update call modifies the data, but we need to trigger the observation
// We wrap the update in a way that SwiftUI's observation system can see the change
cloudSync.update { settings in
transform(&settings)
settings.modificationCount += 1
}
}
/// Debounces save operations for continuous values like sliders
func debouncedSave(key: String, action: @escaping () -> Void) {
// Cancel any pending debounce
debounceTask?.cancel()
// Schedule debounced save
debounceTask = Task {
try? await Task.sleep(for: Self.debounceDelay)
guard !Task.isCancelled else { return }
action()
}
}
// MARK: - Validation
var isValidConfiguration: Bool {
ringSize >= Self.minRingSize
}
}