Files
Reflect/Shared/Models/UserDefaultsStore.swift
Trey T 1f040ab676 v1.1 polish: accessibility, error logging, localization, and code quality sweep
- Wrap 30+ production print() statements in #if DEBUG guards across 18 files
- Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets
- Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views
- Add text alternatives for color-only indicators (progress dots, mood circles)
- Localize raw string literals in NoteEditorView, EntryDetailView, widgets
- Replace 25+ silent try? with do/catch + AppLogger error logging
- Replace hardcoded font sizes with semantic Dynamic Type fonts
- Fix FIXME in IconPickerView (log icon change errors)
- Extract magic animation delays to named constants across 8 files
- Add widget empty state "Log your first mood!" messaging
- Hide decorative images from VoiceOver, add labels to ColorPickers
- Remove stale TODO in Color+Codable (alpha change deferred for migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:14 -05:00

467 lines
18 KiB
Swift

//
// 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]
}
}
}