Split SettingsViewModel into logical extensions
- TimerOption.swift - Timer enum moved to Models/ - SettingsViewModel+RingLight.swift - Ring light settings - SettingsViewModel+Camera.swift - Camera, flash, HDR, timer, quality - SettingsViewModel+Premium.swift - Debug toggle and premium reset - SettingsViewModel+CloudSync.swift - iCloud sync conformance - SettingsViewModel.swift - Core class with display/capture settings
This commit is contained in:
parent
a3d7370f32
commit
60ea419a5b
105
SelfieCam/Features/Settings/SettingsViewModel+Camera.swift
Normal file
105
SelfieCam/Features/Settings/SettingsViewModel+Camera.swift
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
//
|
||||||
|
// SettingsViewModel+Camera.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/4/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
import MijickCamera
|
||||||
|
|
||||||
|
// MARK: - Camera Settings
|
||||||
|
|
||||||
|
extension SettingsViewModel {
|
||||||
|
|
||||||
|
// MARK: - Camera Position
|
||||||
|
|
||||||
|
/// Raw camera position string for comparison (avoids MijickCamera enum issues)
|
||||||
|
var cameraPositionRaw: String {
|
||||||
|
cloudSync.data.cameraPositionRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
var cameraPosition: CameraPosition {
|
||||||
|
get {
|
||||||
|
let raw = cloudSync.data.cameraPositionRaw
|
||||||
|
let position: CameraPosition = raw == "front" ? .front : .back
|
||||||
|
Design.debugLog("cameraPosition getter: raw='\(raw)' -> \(position)")
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let rawValue = newValue == .front ? "front" : "back"
|
||||||
|
Design.debugLog("cameraPosition setter: \(newValue) -> raw='\(rawValue)'")
|
||||||
|
updateSettings { $0.cameraPositionRaw = rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Flash Mode
|
||||||
|
|
||||||
|
var flashMode: CameraFlashMode {
|
||||||
|
get { CameraFlashMode(rawValue: cloudSync.data.flashModeRaw) ?? .off }
|
||||||
|
set { updateSettings { $0.flashModeRaw = newValue.rawValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether flash is synced with ring light color (PREMIUM)
|
||||||
|
var isFlashSyncedWithRingLight: Bool {
|
||||||
|
get { PremiumGate.get(cloudSync.data.isFlashSyncedWithRingLight, default: false, isPremium: isPremiumUnlocked) }
|
||||||
|
set {
|
||||||
|
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
||||||
|
updateSettings { $0.isFlashSyncedWithRingLight = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HDR Mode (Premium)
|
||||||
|
|
||||||
|
/// HDR mode setting (PREMIUM)
|
||||||
|
var hdrMode: CameraHDRMode {
|
||||||
|
get {
|
||||||
|
let stored = CameraHDRMode(rawValue: cloudSync.data.hdrModeRaw) ?? .off
|
||||||
|
return PremiumGate.get(stored, default: .off, isPremium: isPremiumUnlocked)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
||||||
|
updateSettings { $0.hdrModeRaw = newValue.rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Photo Quality (High is Premium)
|
||||||
|
|
||||||
|
/// Photo quality setting (high is PREMIUM)
|
||||||
|
var photoQuality: PhotoQuality {
|
||||||
|
get {
|
||||||
|
let stored = PhotoQuality(rawValue: cloudSync.data.photoQualityRaw) ?? PhotoQuality.high
|
||||||
|
return PremiumGate.get(stored, default: .medium, premiumValues: Self.premiumPhotoQualities, isPremium: isPremiumUnlocked)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard PremiumGate.canSet(newValue, premiumValues: Self.premiumPhotoQualities, isPremium: isPremiumUnlocked) else { return }
|
||||||
|
updateSettings { $0.photoQualityRaw = newValue.rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Center Stage (Premium)
|
||||||
|
|
||||||
|
/// Whether Center Stage is enabled (PREMIUM)
|
||||||
|
var isCenterStageEnabled: Bool {
|
||||||
|
get { PremiumGate.get(cloudSync.data.isCenterStageEnabled, default: false, isPremium: isPremiumUnlocked) }
|
||||||
|
set {
|
||||||
|
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
||||||
|
updateSettings { $0.isCenterStageEnabled = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timer (5s/10s are Premium)
|
||||||
|
|
||||||
|
/// Selected timer option (5s and 10s are PREMIUM)
|
||||||
|
var selectedTimer: TimerOption {
|
||||||
|
get {
|
||||||
|
let stored = TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .off
|
||||||
|
return PremiumGate.get(stored, default: .three, premiumValues: Self.premiumTimerOptions, isPremium: isPremiumUnlocked)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard PremiumGate.canSet(newValue, premiumValues: Self.premiumTimerOptions, isPremium: isPremiumUnlocked) else { return }
|
||||||
|
updateSettings { $0.selectedTimerRaw = newValue.rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// SettingsViewModel+CloudSync.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/4/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - CloudSyncable Conformance
|
||||||
|
|
||||||
|
extension SettingsViewModel: CloudSyncable {
|
||||||
|
|
||||||
|
/// Whether iCloud sync is available
|
||||||
|
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
|
||||||
|
|
||||||
|
/// Whether iCloud sync is enabled (available to all users)
|
||||||
|
var iCloudEnabled: Bool {
|
||||||
|
get { cloudSync.iCloudEnabled }
|
||||||
|
set { cloudSync.iCloudEnabled = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last sync date
|
||||||
|
var lastSyncDate: Date? { cloudSync.lastSyncDate }
|
||||||
|
|
||||||
|
/// Current sync status message
|
||||||
|
var syncStatus: String { cloudSync.syncStatus }
|
||||||
|
|
||||||
|
/// Whether initial sync has completed
|
||||||
|
var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync }
|
||||||
|
|
||||||
|
// MARK: - Sync Actions
|
||||||
|
|
||||||
|
/// Forces a sync with iCloud
|
||||||
|
func forceSync() {
|
||||||
|
cloudSync.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets all settings to defaults
|
||||||
|
func resetToDefaults() {
|
||||||
|
cloudSync.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
83
SelfieCam/Features/Settings/SettingsViewModel+Premium.swift
Normal file
83
SelfieCam/Features/Settings/SettingsViewModel+Premium.swift
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
//
|
||||||
|
// SettingsViewModel+Premium.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/4/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Premium Features
|
||||||
|
|
||||||
|
extension SettingsViewModel {
|
||||||
|
|
||||||
|
// MARK: - Debug Premium Toggle
|
||||||
|
|
||||||
|
/// Debug premium toggle for testing (DEBUG builds only)
|
||||||
|
var isDebugPremiumEnabled: Bool {
|
||||||
|
get { premiumManager.isDebugPremiumToggleEnabled }
|
||||||
|
set {
|
||||||
|
let wasPremium = premiumManager.isDebugPremiumToggleEnabled
|
||||||
|
premiumManager.isDebugPremiumToggleEnabled = newValue
|
||||||
|
|
||||||
|
// Update premium status for UI refresh
|
||||||
|
isPremiumUnlocked = premiumManager.isPremiumUnlocked
|
||||||
|
|
||||||
|
// Reset premium settings when toggling OFF
|
||||||
|
if wasPremium && !newValue {
|
||||||
|
resetPremiumSettingsToDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Premium Reset
|
||||||
|
|
||||||
|
/// Reset premium-only settings to their free defaults when debug premium is disabled
|
||||||
|
func resetPremiumSettingsToDefaults() {
|
||||||
|
// Clear cached values that depend on premium status
|
||||||
|
_cachedRingSize = nil
|
||||||
|
_cachedLightColorId = nil
|
||||||
|
_cachedCustomColor = nil
|
||||||
|
|
||||||
|
// Reset premium settings to defaults (bypass premium gates for direct reset)
|
||||||
|
updateSettings { settings in
|
||||||
|
if settings.lightColorId != Self.defaultFreeColorId {
|
||||||
|
settings.lightColorId = Self.defaultFreeColorId
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.isMirrorFlipped {
|
||||||
|
settings.isMirrorFlipped = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.isSkinSmoothingEnabled {
|
||||||
|
settings.isSkinSmoothingEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.isFlashSyncedWithRingLight {
|
||||||
|
settings.isFlashSyncedWithRingLight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.hdrModeRaw != CameraHDRMode.off.rawValue {
|
||||||
|
settings.hdrModeRaw = CameraHDRMode.off.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.photoQualityRaw != PhotoQuality.medium.rawValue {
|
||||||
|
settings.photoQualityRaw = PhotoQuality.medium.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only reset timer if it's a premium option (5s or 10s)
|
||||||
|
let currentTimer = TimerOption(rawValue: settings.selectedTimerRaw) ?? .three
|
||||||
|
if Self.premiumTimerOptions.contains(currentTimer) {
|
||||||
|
settings.selectedTimerRaw = TimerOption.three.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.isCenterStageEnabled {
|
||||||
|
settings.isCenterStageEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force sync to ensure changes are saved
|
||||||
|
forceSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
113
SelfieCam/Features/Settings/SettingsViewModel+RingLight.swift
Normal file
113
SelfieCam/Features/Settings/SettingsViewModel+RingLight.swift
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
//
|
||||||
|
// SettingsViewModel+RingLight.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/4/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Ring Light Settings
|
||||||
|
|
||||||
|
extension SettingsViewModel {
|
||||||
|
|
||||||
|
// MARK: - Ring Size
|
||||||
|
|
||||||
|
/// Ring border size in points (debounced save)
|
||||||
|
var ringSize: CGFloat {
|
||||||
|
get { _cachedRingSize ?? cloudSync.data.ringSize }
|
||||||
|
set {
|
||||||
|
_cachedRingSize = newValue
|
||||||
|
debouncedSave(key: "ringSize") {
|
||||||
|
self._cachedRingSize = nil
|
||||||
|
self.updateSettings { $0.ringSize = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience property for border width (same as ringSize)
|
||||||
|
var borderWidth: CGFloat { ringSize }
|
||||||
|
|
||||||
|
// MARK: - Ring Light Enabled/Opacity
|
||||||
|
|
||||||
|
var isRingLightEnabled: Bool {
|
||||||
|
get { cloudSync.data.isRingLightEnabled }
|
||||||
|
set { updateSettings { $0.isRingLightEnabled = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var ringLightOpacity: Double {
|
||||||
|
get { cloudSync.data.ringLightOpacity }
|
||||||
|
set { updateSettings { $0.ringLightOpacity = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Light Color
|
||||||
|
|
||||||
|
/// ID of the selected light color preset
|
||||||
|
var lightColorId: String {
|
||||||
|
get {
|
||||||
|
let storedId = _cachedLightColorId ?? cloudSync.data.lightColorId
|
||||||
|
return PremiumGate.get(storedId, default: Self.defaultFreeColorId, premiumValues: Self.premiumColorIds, isPremium: isPremiumUnlocked)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard PremiumGate.canSet(newValue, premiumValues: Self.premiumColorIds, isPremium: isPremiumUnlocked) else { return }
|
||||||
|
_cachedLightColorId = newValue
|
||||||
|
updateSettings { $0.lightColorId = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom color for ring light (premium feature, debounced save)
|
||||||
|
var customColor: Color {
|
||||||
|
get {
|
||||||
|
_cachedCustomColor ?? Color(
|
||||||
|
red: cloudSync.data.customColorRed,
|
||||||
|
green: cloudSync.data.customColorGreen,
|
||||||
|
blue: cloudSync.data.customColorBlue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
||||||
|
_cachedCustomColor = newValue
|
||||||
|
let rgb = CustomColorRGB(from: newValue)
|
||||||
|
debouncedSave(key: "customColor") {
|
||||||
|
self._cachedCustomColor = nil
|
||||||
|
self.updateSettings {
|
||||||
|
$0.customColorRed = rgb.red
|
||||||
|
$0.customColorGreen = rgb.green
|
||||||
|
$0.customColorBlue = rgb.blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The currently selected light color preset
|
||||||
|
var selectedLightColor: RingLightColor {
|
||||||
|
get { RingLightColor.fromId(lightColorId, customColor: customColor) }
|
||||||
|
set {
|
||||||
|
lightColorId = newValue.id
|
||||||
|
if newValue.isCustom {
|
||||||
|
customColor = newValue.color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actual color to use for the ring light
|
||||||
|
var lightColor: Color {
|
||||||
|
if lightColorId == RingLightColor.customId {
|
||||||
|
return customColor
|
||||||
|
}
|
||||||
|
return selectedLightColor.color
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether custom color is currently selected
|
||||||
|
var isCustomColorSelected: Bool {
|
||||||
|
lightColorId == RingLightColor.customId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the custom color and selects it (PREMIUM)
|
||||||
|
func selectCustomColor(_ color: Color) {
|
||||||
|
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
||||||
|
customColor = color
|
||||||
|
lightColorId = RingLightColor.customId
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,44 +1,28 @@
|
|||||||
|
//
|
||||||
|
// SettingsViewModel.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/4/26.
|
||||||
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import MijickCamera
|
import MijickCamera
|
||||||
|
|
||||||
// MARK: - Timer Options
|
|
||||||
|
|
||||||
enum TimerOption: String, CaseIterable, Identifiable {
|
|
||||||
case off = "off"
|
|
||||||
case three = "3"
|
|
||||||
case five = "5"
|
|
||||||
case ten = "10"
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .off: return String(localized: "Off")
|
|
||||||
case .three: return String(localized: "3s")
|
|
||||||
case .five: return String(localized: "5s")
|
|
||||||
case .ten: return String(localized: "10s")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var seconds: Int {
|
|
||||||
switch self {
|
|
||||||
case .off: return 0
|
|
||||||
case .three: return 3
|
|
||||||
case .five: return 5
|
|
||||||
case .ten: return 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Settings ViewModel
|
// MARK: - Settings ViewModel
|
||||||
|
|
||||||
/// Observable settings view model with iCloud sync across all devices.
|
/// Observable settings view model with iCloud sync across all devices.
|
||||||
/// Uses Bedrock's CloudSyncManager for automatic synchronization.
|
/// Uses Bedrock's CloudSyncManager for automatic synchronization.
|
||||||
/// Premium features are automatically reset to defaults when user doesn't have premium.
|
/// 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
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class SettingsViewModel: RingLightConfigurable, CloudSyncable {
|
final class SettingsViewModel: RingLightConfigurable {
|
||||||
|
|
||||||
// MARK: - Ring Size Limits
|
// MARK: - Ring Size Limits
|
||||||
|
|
||||||
@ -51,10 +35,46 @@ final class SettingsViewModel: RingLightConfigurable, CloudSyncable {
|
|||||||
/// Default ring border size
|
/// Default ring border size
|
||||||
static let defaultRingSize: CGFloat = 40
|
static let defaultRingSize: CGFloat = 40
|
||||||
|
|
||||||
// MARK: - Premium Manager
|
// 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
|
/// Premium manager for checking subscription status
|
||||||
@ObservationIgnored private let premiumManager = PremiumManager()
|
@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
|
||||||
|
|
||||||
/// Whether the user has premium access (stored property for proper UI updates)
|
/// Whether the user has premium access (stored property for proper UI updates)
|
||||||
private var _isPremiumUnlocked: Bool = false
|
private var _isPremiumUnlocked: Bool = false
|
||||||
@ -62,88 +82,10 @@ final class SettingsViewModel: RingLightConfigurable, CloudSyncable {
|
|||||||
/// Whether the user has premium access (observable for UI updates)
|
/// Whether the user has premium access (observable for UI updates)
|
||||||
var isPremiumUnlocked: Bool {
|
var isPremiumUnlocked: Bool {
|
||||||
get { _isPremiumUnlocked }
|
get { _isPremiumUnlocked }
|
||||||
set {
|
set { _isPremiumUnlocked = newValue }
|
||||||
_isPremiumUnlocked = newValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cloud Sync Manager
|
// MARK: - Display Settings
|
||||||
|
|
||||||
/// Manages iCloud sync for settings across all devices
|
|
||||||
private let cloudSync = CloudSyncManager<SyncedSettings>()
|
|
||||||
|
|
||||||
/// Debounce task for slider values
|
|
||||||
private var debounceTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
/// Debounce delay for continuous slider updates (in seconds)
|
|
||||||
private static let debounceDelay: Duration = .milliseconds(300)
|
|
||||||
|
|
||||||
/// Cached ring size for immediate UI updates (before debounced save)
|
|
||||||
private var _cachedRingSize: CGFloat?
|
|
||||||
|
|
||||||
// MARK: - Observable Properties (Synced)
|
|
||||||
|
|
||||||
/// Ring border size in points (debounced save)
|
|
||||||
var ringSize: CGFloat {
|
|
||||||
get { _cachedRingSize ?? cloudSync.data.ringSize }
|
|
||||||
set {
|
|
||||||
_cachedRingSize = newValue
|
|
||||||
debouncedSave(key: "ringSize") {
|
|
||||||
self._cachedRingSize = nil
|
|
||||||
self.updateSettings { $0.ringSize = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached light color ID for immediate UI updates
|
|
||||||
private var _cachedLightColorId: String?
|
|
||||||
|
|
||||||
/// Default free color ID for non-premium users
|
|
||||||
private static let defaultFreeColorId = "pureWhite"
|
|
||||||
|
|
||||||
/// Premium color IDs that require subscription
|
|
||||||
private static let premiumColorIds: Set<String> = ["iceBlue", "softPink", "warmAmber", "coolLavender", RingLightColor.customId]
|
|
||||||
|
|
||||||
/// ID of the selected light color preset
|
|
||||||
var lightColorId: String {
|
|
||||||
get {
|
|
||||||
let storedId = _cachedLightColorId ?? cloudSync.data.lightColorId
|
|
||||||
return PremiumGate.get(storedId, default: Self.defaultFreeColorId, premiumValues: Self.premiumColorIds, isPremium: isPremiumUnlocked)
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
guard PremiumGate.canSet(newValue, premiumValues: Self.premiumColorIds, isPremium: isPremiumUnlocked) else { return }
|
|
||||||
_cachedLightColorId = newValue
|
|
||||||
updateSettings { $0.lightColorId = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached custom color for immediate UI updates
|
|
||||||
private var _cachedCustomColor: Color?
|
|
||||||
|
|
||||||
/// Custom color for ring light (premium feature, debounced save)
|
|
||||||
/// Note: Getter always returns stored value to preserve user's choice if they re-subscribe
|
|
||||||
var customColor: Color {
|
|
||||||
get {
|
|
||||||
_cachedCustomColor ?? Color(
|
|
||||||
red: cloudSync.data.customColorRed,
|
|
||||||
green: cloudSync.data.customColorGreen,
|
|
||||||
blue: cloudSync.data.customColorBlue
|
|
||||||
)
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
|
||||||
_cachedCustomColor = newValue
|
|
||||||
let rgb = CustomColorRGB(from: newValue)
|
|
||||||
debouncedSave(key: "customColor") {
|
|
||||||
self._cachedCustomColor = nil
|
|
||||||
self.updateSettings {
|
|
||||||
$0.customColorRed = rgb.red
|
|
||||||
$0.customColorGreen = rgb.green
|
|
||||||
$0.customColorBlue = rgb.blue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the camera preview is flipped to show a true mirror (PREMIUM)
|
/// Whether the camera preview is flipped to show a true mirror (PREMIUM)
|
||||||
var isMirrorFlipped: Bool {
|
var isMirrorFlipped: Bool {
|
||||||
@ -175,230 +117,14 @@ final class SettingsViewModel: RingLightConfigurable, CloudSyncable {
|
|||||||
set { updateSettings { $0.currentZoomFactor = newValue } }
|
set { updateSettings { $0.currentZoomFactor = newValue } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture Settings
|
||||||
|
|
||||||
/// Whether captures are auto-saved to Photo Library
|
/// Whether captures are auto-saved to Photo Library
|
||||||
var isAutoSaveEnabled: Bool {
|
var isAutoSaveEnabled: Bool {
|
||||||
get { cloudSync.data.isAutoSaveEnabled }
|
get { cloudSync.data.isAutoSaveEnabled }
|
||||||
set { updateSettings { $0.isAutoSaveEnabled = newValue } }
|
set { updateSettings { $0.isAutoSaveEnabled = newValue } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
|
||||||
|
|
||||||
/// Convenience property for border width (same as ringSize)
|
|
||||||
var borderWidth: CGFloat { ringSize }
|
|
||||||
|
|
||||||
/// Premium timer options that require subscription
|
|
||||||
private static let premiumTimerOptions: Set<TimerOption> = [.five, .ten]
|
|
||||||
|
|
||||||
/// Selected timer option (5s and 10s are PREMIUM)
|
|
||||||
var selectedTimer: TimerOption {
|
|
||||||
get {
|
|
||||||
let stored = TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .off
|
|
||||||
return PremiumGate.get(stored, default: .three, premiumValues: Self.premiumTimerOptions, isPremium: isPremiumUnlocked)
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
guard PremiumGate.canSet(newValue, premiumValues: Self.premiumTimerOptions, isPremium: isPremiumUnlocked) else { return }
|
|
||||||
updateSettings { $0.selectedTimerRaw = newValue.rawValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Camera Settings
|
|
||||||
|
|
||||||
var flashMode: CameraFlashMode {
|
|
||||||
get { CameraFlashMode(rawValue: cloudSync.data.flashModeRaw) ?? .off }
|
|
||||||
set { updateSettings { $0.flashModeRaw = newValue.rawValue } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether flash is synced with ring light color (PREMIUM)
|
|
||||||
var isFlashSyncedWithRingLight: Bool {
|
|
||||||
get { PremiumGate.get(cloudSync.data.isFlashSyncedWithRingLight, default: false, isPremium: isPremiumUnlocked) }
|
|
||||||
set {
|
|
||||||
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
|
||||||
updateSettings { $0.isFlashSyncedWithRingLight = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HDR mode setting (PREMIUM)
|
|
||||||
var hdrMode: CameraHDRMode {
|
|
||||||
get {
|
|
||||||
let stored = CameraHDRMode(rawValue: cloudSync.data.hdrModeRaw) ?? .off
|
|
||||||
return PremiumGate.get(stored, default: .off, isPremium: isPremiumUnlocked)
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
|
||||||
updateSettings { $0.hdrModeRaw = newValue.rawValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Premium photo quality options that require subscription
|
|
||||||
private static let premiumPhotoQualities: Set<PhotoQuality> = [.high]
|
|
||||||
|
|
||||||
/// Photo quality setting (high is PREMIUM)
|
|
||||||
var photoQuality: PhotoQuality {
|
|
||||||
get {
|
|
||||||
let stored = PhotoQuality(rawValue: cloudSync.data.photoQualityRaw) ?? PhotoQuality.high
|
|
||||||
return PremiumGate.get(stored, default: .medium, premiumValues: Self.premiumPhotoQualities, isPremium: isPremiumUnlocked)
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
guard PremiumGate.canSet(newValue, premiumValues: Self.premiumPhotoQualities, isPremium: isPremiumUnlocked) else { return }
|
|
||||||
updateSettings { $0.photoQualityRaw = newValue.rawValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raw camera position string for comparison (avoids MijickCamera enum issues)
|
|
||||||
var cameraPositionRaw: String {
|
|
||||||
cloudSync.data.cameraPositionRaw
|
|
||||||
}
|
|
||||||
|
|
||||||
var cameraPosition: CameraPosition {
|
|
||||||
get {
|
|
||||||
let raw = cloudSync.data.cameraPositionRaw
|
|
||||||
let position: CameraPosition = raw == "front" ? .front : .back
|
|
||||||
Design.debugLog("cameraPosition getter: raw='\(raw)' -> \(position)")
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let rawValue = newValue == .front ? "front" : "back"
|
|
||||||
Design.debugLog("cameraPosition setter: \(newValue) -> raw='\(rawValue)'")
|
|
||||||
updateSettings { $0.cameraPositionRaw = rawValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isRingLightEnabled: Bool {
|
|
||||||
get { cloudSync.data.isRingLightEnabled }
|
|
||||||
set { updateSettings { $0.isRingLightEnabled = newValue } }
|
|
||||||
}
|
|
||||||
|
|
||||||
var ringLightOpacity: Double {
|
|
||||||
get { cloudSync.data.ringLightOpacity }
|
|
||||||
set { updateSettings { $0.ringLightOpacity = newValue } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether Center Stage is enabled (PREMIUM)
|
|
||||||
var isCenterStageEnabled: Bool {
|
|
||||||
get { PremiumGate.get(cloudSync.data.isCenterStageEnabled, default: false, isPremium: isPremiumUnlocked) }
|
|
||||||
set {
|
|
||||||
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
|
||||||
updateSettings { $0.isCenterStageEnabled = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedLightColor: RingLightColor {
|
|
||||||
get { RingLightColor.fromId(lightColorId, customColor: customColor) }
|
|
||||||
set {
|
|
||||||
// Premium check handled by lightColorId and customColor setters
|
|
||||||
lightColorId = newValue.id
|
|
||||||
if newValue.isCustom {
|
|
||||||
customColor = newValue.color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var lightColor: Color {
|
|
||||||
if lightColorId == RingLightColor.customId {
|
|
||||||
return customColor
|
|
||||||
}
|
|
||||||
return selectedLightColor.color
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether custom color is currently selected
|
|
||||||
var isCustomColorSelected: Bool {
|
|
||||||
lightColorId == RingLightColor.customId
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Debug Premium Toggle
|
|
||||||
|
|
||||||
/// Debug premium toggle for testing (DEBUG builds only)
|
|
||||||
var isDebugPremiumEnabled: Bool {
|
|
||||||
get { premiumManager.isDebugPremiumToggleEnabled }
|
|
||||||
set {
|
|
||||||
let wasPremium = premiumManager.isDebugPremiumToggleEnabled
|
|
||||||
premiumManager.isDebugPremiumToggleEnabled = newValue
|
|
||||||
|
|
||||||
// Update premium status for UI refresh
|
|
||||||
isPremiumUnlocked = premiumManager.isPremiumUnlocked
|
|
||||||
|
|
||||||
// Reset premium settings when toggling OFF
|
|
||||||
if wasPremium && !newValue {
|
|
||||||
resetPremiumSettingsToDefaults()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset premium-only settings to their free defaults when debug premium is disabled
|
|
||||||
private func resetPremiumSettingsToDefaults() {
|
|
||||||
// Clear cached values that depend on premium status
|
|
||||||
_cachedRingSize = nil
|
|
||||||
_cachedLightColorId = nil
|
|
||||||
_cachedCustomColor = nil
|
|
||||||
|
|
||||||
// Reset premium settings to defaults (bypass premium gates for direct reset)
|
|
||||||
updateSettings { settings in
|
|
||||||
if settings.lightColorId != Self.defaultFreeColorId {
|
|
||||||
settings.lightColorId = Self.defaultFreeColorId
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.isMirrorFlipped {
|
|
||||||
settings.isMirrorFlipped = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.isSkinSmoothingEnabled {
|
|
||||||
settings.isSkinSmoothingEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.isFlashSyncedWithRingLight {
|
|
||||||
settings.isFlashSyncedWithRingLight = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.hdrModeRaw != CameraHDRMode.off.rawValue {
|
|
||||||
settings.hdrModeRaw = CameraHDRMode.off.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.photoQualityRaw != PhotoQuality.medium.rawValue {
|
|
||||||
settings.photoQualityRaw = PhotoQuality.medium.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only reset timer if it's a premium option (5s or 10s)
|
|
||||||
let currentTimer = TimerOption(rawValue: settings.selectedTimerRaw) ?? .three
|
|
||||||
if Self.premiumTimerOptions.contains(currentTimer) {
|
|
||||||
settings.selectedTimerRaw = TimerOption.three.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.isCenterStageEnabled {
|
|
||||||
settings.isCenterStageEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force sync to ensure changes are saved
|
|
||||||
forceSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the custom color and selects it (PREMIUM)
|
|
||||||
func selectCustomColor(_ color: Color) {
|
|
||||||
guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
|
|
||||||
customColor = color
|
|
||||||
lightColorId = RingLightColor.customId
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sync Status
|
|
||||||
|
|
||||||
/// Whether iCloud sync is available
|
|
||||||
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
|
|
||||||
|
|
||||||
/// Whether iCloud sync is enabled (available to all users)
|
|
||||||
var iCloudEnabled: Bool {
|
|
||||||
get { cloudSync.iCloudEnabled }
|
|
||||||
set { cloudSync.iCloudEnabled = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Last sync date
|
|
||||||
var lastSyncDate: Date? { cloudSync.lastSyncDate }
|
|
||||||
|
|
||||||
/// Current sync status message
|
|
||||||
var syncStatus: String { cloudSync.syncStatus }
|
|
||||||
|
|
||||||
/// Whether initial sync has completed
|
|
||||||
var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync }
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@ -407,10 +133,10 @@ final class SettingsViewModel: RingLightConfigurable, CloudSyncable {
|
|||||||
// CloudSyncManager handles syncing automatically
|
// CloudSyncManager handles syncing automatically
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Internal Methods
|
||||||
|
|
||||||
/// Updates settings and saves to cloud immediately
|
/// Updates settings and saves to cloud immediately
|
||||||
private func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
|
func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
|
||||||
cloudSync.update { settings in
|
cloudSync.update { settings in
|
||||||
transform(&settings)
|
transform(&settings)
|
||||||
settings.modificationCount += 1
|
settings.modificationCount += 1
|
||||||
@ -418,7 +144,7 @@ final class SettingsViewModel: RingLightConfigurable, CloudSyncable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Debounces save operations for continuous values like sliders
|
/// Debounces save operations for continuous values like sliders
|
||||||
private func debouncedSave(key: String, action: @escaping () -> Void) {
|
func debouncedSave(key: String, action: @escaping () -> Void) {
|
||||||
// Cancel any pending debounce
|
// Cancel any pending debounce
|
||||||
debounceTask?.cancel()
|
debounceTask?.cancel()
|
||||||
|
|
||||||
@ -432,18 +158,6 @@ final class SettingsViewModel: RingLightConfigurable, CloudSyncable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sync Actions
|
|
||||||
|
|
||||||
/// Forces a sync with iCloud
|
|
||||||
func forceSync() {
|
|
||||||
cloudSync.sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resets all settings to defaults
|
|
||||||
func resetToDefaults() {
|
|
||||||
cloudSync.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Validation
|
// MARK: - Validation
|
||||||
|
|
||||||
var isValidConfiguration: Bool {
|
var isValidConfiguration: Bool {
|
||||||
|
|||||||
36
SelfieCam/Shared/Models/TimerOption.swift
Normal file
36
SelfieCam/Shared/Models/TimerOption.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// TimerOption.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/4/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Self-timer duration options
|
||||||
|
enum TimerOption: String, CaseIterable, Identifiable {
|
||||||
|
case off = "off"
|
||||||
|
case three = "3"
|
||||||
|
case five = "5"
|
||||||
|
case ten = "10"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return String(localized: "Off")
|
||||||
|
case .three: return String(localized: "3s")
|
||||||
|
case .five: return String(localized: "5s")
|
||||||
|
case .ten: return String(localized: "10s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var seconds: Int {
|
||||||
|
switch self {
|
||||||
|
case .off: return 0
|
||||||
|
case .three: return 3
|
||||||
|
case .five: return 5
|
||||||
|
case .ten: return 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user