476 lines
19 KiB
Swift
476 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 digitScale: Double = AppConstants.Defaults.digitScale
|
|
var stretched: Bool = true
|
|
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 digitScale
|
|
case stretched
|
|
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.digitScale = try container.decodeIfPresent(Double.self, forKey: .digitScale) ?? self.digitScale
|
|
self.stretched = try container.decodeIfPresent(Bool.self, forKey: .stretched) ?? self.stretched
|
|
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(digitScale, forKey: .digitScale)
|
|
try container.encode(stretched, forKey: .stretched)
|
|
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.digitScale == rhs.digitScale &&
|
|
lhs.stretched == rhs.stretched &&
|
|
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
|
|
}
|