178 lines
6.0 KiB
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
|
|
}
|
|
}
|