TheNoiseClock/TheNoiseClock/Features/Clock/Models/ClockStyle.swift

466 lines
19 KiB
Swift

//
// ClockStyle.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
import Observation
import Bedrock
/// Clock customization settings and data model
@Observable
class ClockStyle: Codable, Equatable {
// MARK: - Time Format Settings
var use24Hour: Bool = true
var showSeconds: Bool = false
var showAmPm: Bool = true
var forceHorizontalMode: Bool = false // Force horizontal layout even in portrait
// MARK: - Visual Settings
var digitColorHex: String = AppConstants.Defaults.digitColorHex
var randomizeColor: Bool = false
var glowIntensity: Double = AppConstants.Defaults.glowIntensity
var backgroundHex: String = AppConstants.Defaults.backgroundColorHex
// MARK: - Color Theme Settings
var selectedColorTheme: String = "Custom" // Custom, Night, Day, Red, Orange, Yellow, Green, Blue, Purple, Pink, White
// MARK: - Night Mode Settings
var nightModeEnabled: Bool = false
var autoNightMode: Bool = false
var scheduledNightMode: Bool = false
var nightModeStartTime: String = "22:00" // 10:00 PM
var nightModeEndTime: String = "06:00" // 6:00 AM
var autoBrightness: Bool = true // Automatically dim brightness in night mode
var ambientLightThreshold: Double = 0.3 // Threshold for ambient light detection (0.0-1.0)
// MARK: - Font Settings
var fontFamily: FontFamily = .system
var fontWeight: Font.Weight = .bold
var fontDesign: Font.Design = .rounded
// MARK: - Overlay Settings
var showBattery: Bool = true
var showDate: Bool = true
var dateFormat: String = "d MMMM EEE" // Default: "7 September Mon"
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
// MARK: - Display Settings
var keepAwake: Bool = false // Keep screen awake in display mode
var respectFocusModes: Bool = true // Respect Focus mode settings for audio
// MARK: - Cached Colors
private var _cachedDigitColor: Color?
private var _cachedBackgroundColor: Color?
// MARK: - Codable Keys
private enum CodingKeys: String, CodingKey {
case use24Hour
case showSeconds
case showAmPm
case forceHorizontalMode
case digitColorHex
case randomizeColor
case glowIntensity
case backgroundHex
case selectedColorTheme
case nightModeEnabled
case autoNightMode
case scheduledNightMode
case nightModeStartTime
case nightModeEndTime
case autoBrightness
case ambientLightThreshold
case fontFamily
case fontWeight
case fontDesign
case showBattery
case showDate
case dateFormat
case clockOpacity
case overlayOpacity
case keepAwake
case respectFocusModes
}
// MARK: - Initialization
init() {
// Defaults already set in property declarations
}
// MARK: - Codable Implementation
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour
self.showSeconds = try container.decodeIfPresent(Bool.self, forKey: .showSeconds) ?? self.showSeconds
self.showAmPm = try container.decodeIfPresent(Bool.self, forKey: .showAmPm) ?? self.showAmPm
self.forceHorizontalMode = try container.decodeIfPresent(Bool.self, forKey: .forceHorizontalMode) ?? self.forceHorizontalMode
self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex
self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor
self.glowIntensity = try container.decodeIfPresent(Double.self, forKey: .glowIntensity) ?? self.glowIntensity
self.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex
self.selectedColorTheme = try container.decodeIfPresent(String.self, forKey: .selectedColorTheme) ?? self.selectedColorTheme
self.nightModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .nightModeEnabled) ?? self.nightModeEnabled
self.autoNightMode = try container.decodeIfPresent(Bool.self, forKey: .autoNightMode) ?? self.autoNightMode
self.scheduledNightMode = try container.decodeIfPresent(Bool.self, forKey: .scheduledNightMode) ?? self.scheduledNightMode
self.nightModeStartTime = try container.decodeIfPresent(String.self, forKey: .nightModeStartTime) ?? self.nightModeStartTime
self.nightModeEndTime = try container.decodeIfPresent(String.self, forKey: .nightModeEndTime) ?? self.nightModeEndTime
self.autoBrightness = try container.decodeIfPresent(Bool.self, forKey: .autoBrightness) ?? self.autoBrightness
self.ambientLightThreshold = try container.decodeIfPresent(Double.self, forKey: .ambientLightThreshold) ?? self.ambientLightThreshold
// Decode font settings explicitly from strings to avoid ambiguity
if let fontFamilyRaw = try container.decodeIfPresent(String.self, forKey: .fontFamily),
let decoded = FontFamily(rawValue: fontFamilyRaw) {
self.fontFamily = decoded
}
if let fontWeightString = try container.decodeIfPresent(String.self, forKey: .fontWeight),
let decoded = Font.Weight(rawValue: fontWeightString) {
self.fontWeight = decoded
}
if let fontDesignString = try container.decodeIfPresent(String.self, forKey: .fontDesign),
let decoded = Font.Design(rawValue: fontDesignString) {
self.fontDesign = decoded
}
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
self.respectFocusModes = try container.decodeIfPresent(Bool.self, forKey: .respectFocusModes) ?? self.respectFocusModes
clearColorCache()
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(use24Hour, forKey: .use24Hour)
try container.encode(showSeconds, forKey: .showSeconds)
try container.encode(showAmPm, forKey: .showAmPm)
try container.encode(forceHorizontalMode, forKey: .forceHorizontalMode)
try container.encode(digitColorHex, forKey: .digitColorHex)
try container.encode(randomizeColor, forKey: .randomizeColor)
try container.encode(glowIntensity, forKey: .glowIntensity)
try container.encode(backgroundHex, forKey: .backgroundHex)
try container.encode(selectedColorTheme, forKey: .selectedColorTheme)
try container.encode(nightModeEnabled, forKey: .nightModeEnabled)
try container.encode(autoNightMode, forKey: .autoNightMode)
try container.encode(scheduledNightMode, forKey: .scheduledNightMode)
try container.encode(nightModeStartTime, forKey: .nightModeStartTime)
try container.encode(nightModeEndTime, forKey: .nightModeEndTime)
try container.encode(autoBrightness, forKey: .autoBrightness)
try container.encode(ambientLightThreshold, forKey: .ambientLightThreshold)
try container.encode(fontFamily.rawValue, forKey: .fontFamily)
try container.encode(fontWeight.rawValue, forKey: .fontWeight)
try container.encode(fontDesign.rawValue, forKey: .fontDesign)
try container.encode(showBattery, forKey: .showBattery)
try container.encode(showDate, forKey: .showDate)
try container.encode(dateFormat, forKey: .dateFormat)
try container.encode(clockOpacity, forKey: .clockOpacity)
try container.encode(overlayOpacity, forKey: .overlayOpacity)
try container.encode(keepAwake, forKey: .keepAwake)
try container.encode(respectFocusModes, forKey: .respectFocusModes)
}
// MARK: - Computed Properties
var digitColor: Color {
if let cached = _cachedDigitColor {
return cached
}
let color = Color(hex: digitColorHex) ?? .white
_cachedDigitColor = color
return color
}
var backgroundColor: Color {
if let cached = _cachedBackgroundColor {
return cached
}
let color = Color(hex: backgroundHex) ?? .black
_cachedBackgroundColor = color
return color
}
// MARK: - Helper Methods
func clearColorCache() {
_cachedDigitColor = nil
_cachedBackgroundColor = nil
}
/// Apply a predefined color theme
func applyColorTheme(_ theme: String) {
selectedColorTheme = theme
switch theme {
case "Night":
digitColorHex = "#FFFFFF"
backgroundHex = "#000000"
case "Day":
digitColorHex = "#000000"
backgroundHex = "#FFFFFF"
case "Red":
digitColorHex = "#FF3B30"
backgroundHex = "#000000"
case "Orange":
digitColorHex = "#FF9500"
backgroundHex = "#000000"
case "Yellow":
digitColorHex = "#FFCC00"
backgroundHex = "#000000"
case "Green":
digitColorHex = "#34C759"
backgroundHex = "#000000"
case "Blue":
digitColorHex = "#007AFF"
backgroundHex = "#000000"
case "Purple":
digitColorHex = "#AF52DE"
backgroundHex = "#000000"
case "Pink":
digitColorHex = "#FF2D92"
backgroundHex = "#000000"
case "White":
digitColorHex = "#FFFFFF"
backgroundHex = "#000000"
default:
// Custom theme - don't change colors
break
}
clearColorCache()
}
/// Get available color themes
static func availableColorThemes() -> [(String, String)] {
return [
("Custom", "Custom"),
("Night", "Night"),
("Day", "Day"),
("Red", "Red"),
("Orange", "Orange"),
("Yellow", "Yellow"),
("Green", "Green"),
("Blue", "Blue"),
("Purple", "Purple"),
("Pink", "Pink"),
("White", "White")
]
}
/// Check if night mode should be active based on current settings
var isNightModeActive: Bool {
if nightModeEnabled {
return true
}
if scheduledNightMode {
return isWithinScheduledNightMode()
}
if autoNightMode {
return isAmbientLightLow()
}
return false
}
/// Check if current time is within scheduled night mode hours
private func isWithinScheduledNightMode() -> Bool {
let now = Date()
let calendar = Calendar.current
let currentTime = calendar.dateComponents([.hour, .minute], from: now)
let startComponents = parseTimeString(nightModeStartTime)
let endComponents = parseTimeString(nightModeEndTime)
guard let startHour = startComponents.hour,
let startMinute = startComponents.minute,
let endHour = endComponents.hour,
let endMinute = endComponents.minute else {
return false
}
let currentMinutes = (currentTime.hour ?? 0) * 60 + (currentTime.minute ?? 0)
let startMinutes = startHour * 60 + startMinute
let endMinutes = endHour * 60 + endMinute
// Handle overnight schedules (e.g., 22:00 to 06:00)
if startMinutes > endMinutes {
return currentMinutes >= startMinutes || currentMinutes < endMinutes
} else {
return currentMinutes >= startMinutes && currentMinutes < endMinutes
}
}
/// Parse time string in HH:mm format
private func parseTimeString(_ timeString: String) -> (hour: Int?, minute: Int?) {
let components = timeString.split(separator: ":")
guard components.count == 2,
let hour = Int(components[0]),
let minute = Int(components[1]) else {
return (nil, nil)
}
return (hour, minute)
}
/// Get the effective digit color considering night mode
var effectiveDigitColor: Color {
if isNightModeActive {
return Color(hex: "#FF3B30") ?? .red // Red for night mode
}
return digitColor
}
/// Get the effective background color considering night mode
var effectiveBackgroundColor: Color {
if isNightModeActive {
return Color(hex: "#000000") ?? .black // Black background for night mode
}
return backgroundColor
}
/// Check if ambient light is low enough to trigger night mode
private func isAmbientLightLow() -> Bool {
// Use screen brightness as a proxy for ambient light
// In a real implementation, you'd use the ambient light sensor
let currentBrightness = UIScreen.main.brightness
return currentBrightness < ambientLightThreshold
}
/// Get the effective brightness considering color theme and night mode
var effectiveBrightness: Double {
if !autoBrightness {
Design.debugLog("[brightness] effectiveBrightness: Auto-brightness disabled, returning 1.0")
return 1.0 // Full brightness when auto-brightness is disabled
}
if isNightModeActive {
Design.debugLog("[brightness] effectiveBrightness: Night mode active, returning 0.3")
// Dim the display to 30% brightness in night mode
return 0.3
}
// Color-aware brightness adaptation
let colorAwareBrightness = getColorAwareBrightness()
Design.debugLog("[brightness] effectiveBrightness: Color-aware brightness = \(String(format: "%.2f", colorAwareBrightness))")
return colorAwareBrightness
}
/// Get brightness based on color theme and ambient light
private func getColorAwareBrightness() -> Double {
let baseBrightness = getBaseBrightnessForColor()
let ambientFactor = getAmbientLightFactor()
// Combine color-based brightness with ambient light factor
return max(0.2, min(1.0, baseBrightness * ambientFactor))
}
/// Get base brightness recommendation for current color theme
private func getBaseBrightnessForColor() -> Double {
switch selectedColorTheme {
case "Night":
return 0.8
case "Day":
return 0.5
case "Red", "Orange":
return 0.6 // Warmer colors work well at lower brightness
case "Yellow", "White":
return 0.8 // Bright colors can be brighter
case "Green", "Blue":
return 0.7 // Cool colors at medium brightness
case "Purple", "Pink":
return 0.65 // Vibrant colors at slightly lower brightness
default:
// For custom colors, analyze the actual color
return getBrightnessForCustomColor()
}
}
/// Get brightness for custom colors based on color properties
private func getBrightnessForCustomColor() -> Double {
guard let color = Color(hex: digitColorHex) else { return 0.7 }
// Convert to UIColor to analyze brightness
let uiColor = UIColor(color)
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
// Calculate perceived brightness (luminance)
let brightness = 0.299 * red + 0.587 * green + 0.114 * blue
// Map brightness to recommended display brightness
if brightness > 0.8 {
return 0.8 // Very bright colors
} else if brightness > 0.6 {
return 0.7 // Medium bright colors
} else if brightness > 0.4 {
return 0.6 // Darker colors
} else {
return 0.5 // Very dark colors
}
}
/// Get ambient light factor (0.5 to 1.0)
private func getAmbientLightFactor() -> Double {
let currentBrightness = UIScreen.main.brightness
// Map screen brightness to ambient light factor
if currentBrightness < 0.2 {
return 0.5 // Very dark environment
} else if currentBrightness < 0.4 {
return 0.7 // Dark environment
} else if currentBrightness < 0.6 {
return 0.85 // Medium environment
} else {
return 1.0 // Bright environment
}
}
/// Get the night mode red tint intensity
var nightModeTintIntensity: Double {
if isNightModeActive {
return 0.8 // Strong red tint for night mode
}
return 0.0 // No tint
}
// MARK: - Equatable
static func == (lhs: ClockStyle, rhs: ClockStyle) -> Bool {
lhs.use24Hour == rhs.use24Hour &&
lhs.showSeconds == rhs.showSeconds &&
lhs.showAmPm == rhs.showAmPm &&
lhs.forceHorizontalMode == rhs.forceHorizontalMode &&
lhs.digitColorHex == rhs.digitColorHex &&
lhs.randomizeColor == rhs.randomizeColor &&
lhs.glowIntensity == rhs.glowIntensity &&
lhs.backgroundHex == rhs.backgroundHex &&
lhs.selectedColorTheme == rhs.selectedColorTheme &&
lhs.nightModeEnabled == rhs.nightModeEnabled &&
lhs.autoNightMode == rhs.autoNightMode &&
lhs.scheduledNightMode == rhs.scheduledNightMode &&
lhs.nightModeStartTime == rhs.nightModeStartTime &&
lhs.nightModeEndTime == rhs.nightModeEndTime &&
lhs.autoBrightness == rhs.autoBrightness &&
lhs.ambientLightThreshold == rhs.ambientLightThreshold &&
lhs.fontFamily == rhs.fontFamily &&
lhs.fontWeight == rhs.fontWeight &&
lhs.fontDesign == rhs.fontDesign &&
lhs.showBattery == rhs.showBattery &&
lhs.showDate == rhs.showDate &&
lhs.dateFormat == rhs.dateFormat &&
lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity &&
lhs.keepAwake == rhs.keepAwake &&
lhs.respectFocusModes == rhs.respectFocusModes
}
}
// MARK: - Storage Key
extension ClockStyle {
static let appStorageKey = AppConstants.StorageKeys.clockStyle
}