// // UserDefaultsStore.swift // Reflect (iOS) // // Created by Trey Tartt on 1/22/22. // import Foundation import os.log enum VotingLayoutStyle: Int, CaseIterable { case horizontal = 0 // Current: 5 buttons in a row case cards = 1 // Larger tappable cards with labels case stacked = 2 // Full-width vertical list case aura = 3 // Atmospheric glowing orbs with flowing layout case orbit = 4 // Celestial orbit with center core case neon = 5 // Synthwave arcade equalizer with glowing segments var displayName: String { switch self { case .horizontal: return "Horizontal" case .cards: return "Cards" case .stacked: return "Stacked" case .aura: return "Aura" case .orbit: return "Orbit" case .neon: return "Neon" } } } enum PaywallStyle: Int, CaseIterable { case celestial = 0 // Celestial Self-Discovery - aurora, floating orbs case garden = 1 // Garden Growth - organic, blooming nature case neon = 2 // Neon Pulse - synthwave, energetic case minimal = 3 // Minimal Zen - clean, sophisticated case zen = 4 // Zen Garden - ink brushstrokes, meditation case editorial = 5 // Editorial - magazine typography case mixtape = 6 // Mixtape - cassette/retro analog case heartfelt = 7 // Heartfelt - hearts and emotion case luxe = 8 // Luxe - premium glass materials case forecast = 9 // Forecast - weather metaphors case playful = 10 // Playful - vibrant emoji patterns case journal = 11 // Journal - handwritten paper var displayName: String { switch self { case .celestial: return "Celestial" case .garden: return "Garden" case .neon: return "Neon" case .minimal: return "Minimal" case .zen: return "Zen" case .editorial: return "Editorial" case .mixtape: return "Mixtape" case .heartfelt: return "Heartfelt" case .luxe: return "Luxe" case .forecast: return "Forecast" case .playful: return "Playful" case .journal: return "Journal" } } var description: String { switch self { case .celestial: return "Aurora lights & floating emotion orbs" case .garden: return "Blooming flowers & organic growth" case .neon: return "Synthwave energy & glowing pulses" case .minimal: return "Clean typography & subtle elegance" case .zen: return "Ink brushstrokes & meditative calm" case .editorial: return "Magazine typography & literary elegance" case .mixtape: return "Cassette tapes & analog nostalgia" case .heartfelt: return "Hearts & emotional expression" case .luxe: return "Premium glass & refined materials" case .forecast: return "Weather metaphors & natural flow" case .playful: return "Vibrant patterns & playful energy" case .journal: return "Handwritten notes & paper textures" } } } enum LockScreenStyle: Int, CaseIterable { case aurora = 0 // Default - emotional aurora with breathing orb case zen = 1 // Zen - ink circles, minimal, calming case neon = 2 // Neon - synthwave grid, glowing elements case celestial = 3 // Celestial - stars, moon phases, cosmic case editorial = 4 // Editorial - typography focused, clean case mixtape = 5 // Mixtape - cassette aesthetic, retro case bloom = 6 // Bloom - organic shapes, flowers case heartfelt = 7 // Heartfelt - hearts, soft gradients case minimal = 8 // Minimal - ultra clean, simple case luxe = 9 // Luxe - glass, premium materials case forecast = 10 // Forecast - weather, atmospheric case playful = 11 // Playful - patterns, vibrant case journal = 12 // Journal - paper, handwritten feel var displayName: String { switch self { case .aurora: return "Aurora" case .zen: return "Zen" case .neon: return "Neon" case .celestial: return "Celestial" case .editorial: return "Editorial" case .mixtape: return "Mixtape" case .bloom: return "Bloom" case .heartfelt: return "Heartfelt" case .minimal: return "Minimal" case .luxe: return "Luxe" case .forecast: return "Forecast" case .playful: return "Playful" case .journal: return "Journal" } } } enum DayViewStyle: Int, CaseIterable { case classic = 0 // Current card style with gradient icons case minimal = 1 // Clean, simple flat cards case compact = 2 // Dense timeline view case bubble = 3 // Colorful full-width bubbles case grid = 4 // 3 entries per row grid case aura = 5 // Atmospheric glowing entries with giant typography case chronicle = 6 // Editorial magazine with dramatic serif typography case neon = 7 // Cyberpunk synthwave with glowing edges case ink = 8 // Japanese zen calligraphy with brush strokes case prism = 9 // Premium glassmorphism with light refraction case tape = 10 // Retro cassette mixtape aesthetic case morph = 11 // Liquid organic blob shapes case stack = 12 // Layered paper notes with depth case wave = 13 // Horizontal gradient river bands case pattern = 14 // Mood icons as repeating background pattern case leather = 15 // Skeuomorphic leather with stitching case glass = 16 // Liquid glass with variable blur case motion = 17 // Accelerometer-driven parallax effect case micro = 18 // Ultra compact single-line entries case orbit = 19 // Celestial circular orbital arrangement var displayName: String { switch self { case .classic: return "Classic" case .minimal: return "Minimal" case .compact: return "Compact" case .bubble: return "Bubble" case .grid: return "Grid" case .aura: return "Aura" case .chronicle: return "Chronicle" case .neon: return "Neon" case .ink: return "Ink" case .prism: return "Prism" case .tape: return "Tape" case .morph: return "Morph" case .stack: return "Stack" case .wave: return "Wave" case .pattern: return "Pattern" case .leather: return "Leather" case .glass: return "Glass" case .motion: return "Motion" case .micro: return "Micro" case .orbit: return "Orbit" } } var isGridLayout: Bool { self == .grid } /// Styles available in the picker (some are disabled/experimental) var isAvailable: Bool { switch self { case .motion, .leather, .wave, .morph, .prism, .ink: return false default: return true } } /// All styles available to users static var availableCases: [DayViewStyle] { allCases.filter { $0.isAvailable } } } private let userDefaultsLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", category: "UserDefaults") class UserDefaultsStore { enum Keys: String { case savedOnboardingData case needsOnboarding case useCloudKit case deleteEnable case mainViewTopHeaderIndex case theme case moodImages case moodTint case personalityPack case customWidget case customMoodTint case customMoodTintUpdateNumber case showNSFW case shape case daysFilter case firstLaunchDate case hasActiveSubscription case cachedSubscriptionExpiration case lastVotedDate case votingLayoutStyle case dayViewStyle case privacyLockEnabled case healthKitEnabled case healthKitSyncEnabled case paywallStyle case lockScreenStyle case celebrationAnimation case hapticFeedbackEnabled case weatherEnabled case contentViewCurrentSelectedHeaderViewBackDays case contentViewHeaderTag case contentViewHeaderTagViewOneViewType case contentViewHeaderTagViewTwoViewType case currentSelectedHeaderViewViewType } /// Cached onboarding data to avoid repeated JSON decoding private static var cachedOnboardingData: OnboardingData? static func getOnboarding() -> OnboardingData { // Return cached data if available if let cached = cachedOnboardingData { return cached } // Decode and cache if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data { do { let model = try JSONDecoder().decode(OnboardingData.self, from: data) cachedOnboardingData = model return model } catch { userDefaultsLogger.error("Failed to decode onboarding data: \(error)") } } let defaultData = OnboardingData() cachedOnboardingData = defaultData return defaultData } /// Invalidate cached onboarding data (call when data might have changed externally) static func invalidateOnboardingCache() { cachedOnboardingData = nil } @discardableResult static func saveOnboarding(onboardingData: OnboardingData) -> OnboardingData { // Invalidate cache before saving cachedOnboardingData = nil do { let data = try JSONEncoder().encode(onboardingData) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) } catch { userDefaultsLogger.error("Failed to encode onboarding data: \(error)") } // Re-cache the saved data cachedOnboardingData = onboardingData return onboardingData } static func moodMoodImagable() -> MoodImagable.Type { if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.moodImages.rawValue) as? Int, let model = MoodImages.init(rawValue: data) { return model.moodImages } else { return MoodImages.FontAwesome.moodImages } } static func moodTintable() -> MoodTintable.Type { if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.moodTint.rawValue) as? Int, let model = MoodTints.init(rawValue: data) { return model.moodTints } else { return MoodTints.Default.moodTints } } static func personalityPackable() -> PersonalityPack { if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.personalityPack.rawValue) as? Int, let model = PersonalityPack.init(rawValue: data) { return model } else { return PersonalityPack.Default } } static func theme() -> Theme { // Try String value first (new format) if let stringValue = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.theme.rawValue) as? String, let model = Theme(rawValue: stringValue) { return model } // Fall back to Int value (legacy format for existing users) if let intValue = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.theme.rawValue) as? Int, let model = Theme(legacyIntValue: intValue) { // Migrate to new String format GroupUserDefaults.groupDefaults.set(model.rawValue, forKey: UserDefaultsStore.Keys.theme.rawValue) return model } return Theme.system } /// Call this on app launch to migrate any legacy Int-based theme values to String format static func migrateThemeIfNeeded() { // Check if we have an Int value stored (legacy format) if let intValue = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.theme.rawValue) as? Int, let model = Theme(legacyIntValue: intValue) { // Migrate to new String format GroupUserDefaults.groupDefaults.set(model.rawValue, forKey: UserDefaultsStore.Keys.theme.rawValue) GroupUserDefaults.groupDefaults.synchronize() } } static func getCustomWidgets() -> [CustomWidgetModel] { if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data { do { let model = try JSONDecoder().decode([CustomWidgetModel].self, from: data) return model } catch { userDefaultsLogger.error("Failed to decode custom widgets: \(error)") } } GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue) let widget = CustomWidgetModel.randomWidget widget.isSaved = true let widgets = [widget] do { let data = try JSONEncoder().encode(widgets) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue) } catch { userDefaultsLogger.error("Failed to encode default custom widgets: \(error)") return widgets } if let savedData = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data { do { let models = try JSONDecoder().decode([CustomWidgetModel].self, from: savedData) return models.sorted { $0.createdDate < $1.createdDate } } catch { userDefaultsLogger.error("Failed to decode saved custom widgets: \(error)") } } return widgets } @discardableResult static func saveCustomWidget(widgetModel: CustomWidgetModel, inUse: Bool) -> [CustomWidgetModel] { do { var existingWidgets = getCustomWidgets() if let exisitingWidget = existingWidgets.firstIndex(where: { $0.uuid == widgetModel.uuid }) { existingWidgets.remove(at: exisitingWidget) // give it differnet uuid so the view updates widgetModel.uuid = UUID().uuidString } if inUse { existingWidgets.forEach({ $0.inUse = false }) widgetModel.inUse = true } existingWidgets.append(widgetModel) existingWidgets.forEach({ $0.isSaved = true }) let data = try JSONEncoder().encode(existingWidgets) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue) } catch { userDefaultsLogger.error("Failed to encode custom widget for save: \(error)") } return UserDefaultsStore.getCustomWidgets() } @discardableResult static func deleteCustomWidget(withUUID uuid: String) -> [CustomWidgetModel] { do { var existingWidgets = getCustomWidgets() if let exisitingWidget = existingWidgets.firstIndex(where: { $0.uuid == uuid }) { existingWidgets.remove(at: exisitingWidget) } if existingWidgets.count == 0 { let widget = CustomWidgetModel.randomWidget widget.isSaved = true widget.inUse = true existingWidgets.append(widget) } if existingWidgets.first(where: { $0.inUse == true }) == nil { existingWidgets.first?.inUse = true } let data = try JSONEncoder().encode(existingWidgets) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue) } catch { userDefaultsLogger.error("Failed to encode custom widgets for delete: \(error)") } return UserDefaultsStore.getCustomWidgets() } static func getCustomMoodTint() -> SavedMoodTint { if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customMoodTint.rawValue) as? Data{ do { let model = try JSONDecoder().decode(SavedMoodTint.self, from: data) return model } catch { userDefaultsLogger.error("Failed to decode custom mood tint: \(error)") } } return SavedMoodTint() } static func getCustomBGShape() -> BGShape { if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.shape.rawValue) as? Int, let model = BGShape.init(rawValue: data) { return model } else { return BGShape.circle } } @discardableResult static func saveCustomMoodTint(customTint: SavedMoodTint) -> SavedMoodTint { do { let data = try JSONEncoder().encode(customTint) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customMoodTint.rawValue) } catch { userDefaultsLogger.error("Failed to encode custom mood tint: \(error)") } return UserDefaultsStore.getCustomMoodTint() } @discardableResult static func saveDaysFilter(days: [Int]) -> [Int] { GroupUserDefaults.groupDefaults.set(days, forKey: UserDefaultsStore.Keys.daysFilter.rawValue) return UserDefaultsStore.getDaysFilter() } static func getDaysFilter() -> [Int] { if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.daysFilter.rawValue) as? [Int] { return data } else { return [1,2,3,4,5,6,7] } } }