From 05cd4f10e68166e4f6729a68148aee70550f4141 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 10 Sep 2025 12:44:47 -0500 Subject: [PATCH] Signed-off-by: Matt Bruce --- TheNoiseClock/Models/ClockStyle.swift | 265 ++++++++++++++++++ .../Services/AmbientLightService.swift | 76 +++++ TheNoiseClock/ViewModels/ClockViewModel.swift | 32 +++ .../Views/Clock/ClockSettingsView.swift | 157 +++++++++++ TheNoiseClock/Views/Clock/ClockView.swift | 2 +- .../Components/ClockDisplayContainer.swift | 2 +- 6 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 TheNoiseClock/Services/AmbientLightService.swift diff --git a/TheNoiseClock/Models/ClockStyle.swift b/TheNoiseClock/Models/ClockStyle.swift index 9002af3..e9597ea 100644 --- a/TheNoiseClock/Models/ClockStyle.swift +++ b/TheNoiseClock/Models/ClockStyle.swift @@ -24,6 +24,18 @@ class ClockStyle: Codable, Equatable { var stretched: Bool = true var backgroundHex: String = AppConstants.Defaults.backgroundColorHex + // MARK: - Color Theme Settings + var selectedColorTheme: String = "Custom" // Custom, 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: String = "System" // System, San Francisco, etc. var fontWeight: String = "Bold" // Ultra Light, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black @@ -54,6 +66,14 @@ class ClockStyle: Codable, Equatable { 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 @@ -83,6 +103,14 @@ class ClockStyle: Codable, Equatable { 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 self.fontFamily = try container.decodeIfPresent(String.self, forKey: .fontFamily) ?? self.fontFamily self.fontWeight = try container.decodeIfPresent(String.self, forKey: .fontWeight) ?? self.fontWeight self.fontDesign = try container.decodeIfPresent(String.self, forKey: .fontDesign) ?? self.fontDesign @@ -107,6 +135,14 @@ class ClockStyle: Codable, Equatable { 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, forKey: .fontFamily) try container.encode(fontWeight, forKey: .fontWeight) try container.encode(fontDesign, forKey: .fontDesign) @@ -144,6 +180,227 @@ class ClockStyle: Codable, Equatable { _cachedBackgroundColor = nil } + /// Apply a predefined color theme + func applyColorTheme(_ theme: String) { + selectedColorTheme = theme + + switch theme { + 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"), + ("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 { + return 1.0 // Full brightness when auto-brightness is disabled + } + + if isNightModeActive { + // Dim the display to 30% brightness in night mode + return 0.3 + } + + // Color-aware brightness adaptation + return getColorAwareBrightness() + } + + /// 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 "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 && @@ -154,6 +411,14 @@ class ClockStyle: Codable, Equatable { 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 && diff --git a/TheNoiseClock/Services/AmbientLightService.swift b/TheNoiseClock/Services/AmbientLightService.swift new file mode 100644 index 0000000..f180973 --- /dev/null +++ b/TheNoiseClock/Services/AmbientLightService.swift @@ -0,0 +1,76 @@ +// +// AmbientLightService.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/10/25. +// + +import Foundation +import UIKit +import Observation + +/// Service for monitoring ambient light and managing brightness +@Observable +class AmbientLightService { + + // MARK: - Properties + private(set) var currentBrightness: Double = 1.0 + private(set) var isMonitoring = false + + // Timer for periodic brightness checks + private var brightnessTimer: Timer? + + // MARK: - Singleton + static let shared = AmbientLightService() + + private init() { + // Private initializer for singleton + } + + // MARK: - Public Interface + + /// Start monitoring ambient light and brightness + func startMonitoring() { + guard !isMonitoring else { return } + + isMonitoring = true + updateCurrentBrightness() + + // Check brightness every 5 seconds + brightnessTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + self?.updateCurrentBrightness() + } + } + + /// Stop monitoring ambient light and brightness + func stopMonitoring() { + guard isMonitoring else { return } + + isMonitoring = false + brightnessTimer?.invalidate() + brightnessTimer = nil + } + + /// Set screen brightness (0.0 to 1.0) + func setBrightness(_ brightness: Double) { + let clampedBrightness = max(0.0, min(1.0, brightness)) + UIScreen.main.brightness = clampedBrightness + currentBrightness = clampedBrightness + } + + /// Get current screen brightness + func getCurrentBrightness() -> Double { + return UIScreen.main.brightness + } + + /// Check if ambient light is below threshold + func isAmbientLightLow(threshold: Double) -> Bool { + return currentBrightness < threshold + } + + // MARK: - Private Methods + + private func updateCurrentBrightness() { + currentBrightness = UIScreen.main.brightness + } +} diff --git a/TheNoiseClock/ViewModels/ClockViewModel.swift b/TheNoiseClock/ViewModels/ClockViewModel.swift index f1782e2..748aa4f 100644 --- a/TheNoiseClock/ViewModels/ClockViewModel.swift +++ b/TheNoiseClock/ViewModels/ClockViewModel.swift @@ -23,6 +23,9 @@ class ClockViewModel { // Wake lock service private let wakeLockService = WakeLockService.shared + // Ambient light service + private let ambientLightService = AmbientLightService.shared + // Timer management private var secondTimer: Timer.TimerPublisher? private var minuteTimer: Timer.TimerPublisher? @@ -47,10 +50,12 @@ class ClockViewModel { init() { loadStyle() setupTimers() + startAmbientLightMonitoring() } deinit { stopTimers() + stopAmbientLightMonitoring() } // MARK: - Public Interface @@ -68,6 +73,7 @@ class ClockViewModel { saveStyle() updateTimersIfNeeded() updateWakeLockState() + updateBrightness() // Update brightness when style changes } // MARK: - Private Methods @@ -104,7 +110,15 @@ class ClockViewModel { if self.style.randomizeColor { self.style.digitColorHex = Color.randomBrightColorHex() self.saveStyle() + self.updateBrightness() // Update brightness when color changes } + + // Check for night mode state changes (scheduled night mode) + // Force a UI update by updating currentTime slightly + self.currentTime = Date() + + // Update brightness if night mode is active + self.updateBrightness() } } @@ -148,4 +162,22 @@ class ClockViewModel { wakeLockService.disableWakeLock() } } + + /// Start ambient light monitoring + private func startAmbientLightMonitoring() { + ambientLightService.startMonitoring() + } + + /// Stop ambient light monitoring + private func stopAmbientLightMonitoring() { + ambientLightService.stopMonitoring() + } + + /// Update brightness based on color theme and night mode settings + private func updateBrightness() { + if style.autoBrightness { + let targetBrightness = style.effectiveBrightness + ambientLightService.setBrightness(targetBrightness) + } + } } diff --git a/TheNoiseClock/Views/Clock/ClockSettingsView.swift b/TheNoiseClock/Views/Clock/ClockSettingsView.swift index e3ad5f4..e89cb38 100644 --- a/TheNoiseClock/Views/Clock/ClockSettingsView.swift +++ b/TheNoiseClock/Views/Clock/ClockSettingsView.swift @@ -35,6 +35,7 @@ struct ClockSettingsView: View { backgroundColor: $backgroundColor, onCommit: onCommit ) + NightModeSection(style: $style) DisplaySection(style: $style) OverlaySection(style: $style) } @@ -122,6 +123,27 @@ private struct AppearanceSection: View { var body: some View { Section(header: Text("Appearance")) { + // Color Theme Picker + Picker("Color Theme", selection: $style.selectedColorTheme) { + ForEach(ClockStyle.availableColorThemes(), id: \.0) { theme in + HStack { + Circle() + .fill(themeColor(for: theme.0)) + .frame(width: 20, height: 20) + Text(theme.1) + } + .tag(theme.0) + } + } + .pickerStyle(.menu) + .onChange(of: style.selectedColorTheme) { _, newTheme in + if newTheme != "Custom" { + style.applyColorTheme(newTheme) + digitColor = Color(hex: style.digitColorHex) ?? .white + backgroundColor = Color(hex: style.backgroundHex) ?? .black + } + } + ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true) ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false) Toggle("Randomize Color (every minute)", isOn: $style.randomizeColor) @@ -153,13 +175,89 @@ private struct AppearanceSection: View { } .onChange(of: backgroundColor) { _, newValue in style.backgroundHex = newValue.toHex() ?? AppConstants.Defaults.backgroundColorHex + style.selectedColorTheme = "Custom" style.clearColorCache() } .onChange(of: digitColor) { _, newValue in style.digitColorHex = newValue.toHex() ?? AppConstants.Defaults.digitColorHex + style.selectedColorTheme = "Custom" style.clearColorCache() } } + + /// Get the color for a theme + private func themeColor(for theme: String) -> Color { + switch theme { + case "Custom": + return .gray + case "Red": + return .red + case "Orange": + return .orange + case "Yellow": + return .yellow + case "Green": + return .green + case "Blue": + return .blue + case "Purple": + return .purple + case "Pink": + return .pink + case "White": + return .white + default: + return .gray + } + } +} + +private struct NightModeSection: View { + @Binding var style: ClockStyle + + var body: some View { + Section(header: Text("Night Mode"), footer: Text("Night mode displays the clock in red to reduce eye strain in low light environments.")) { + Toggle("Enable Night Mode", isOn: $style.nightModeEnabled) + + Toggle("Auto Night Mode", isOn: $style.autoNightMode) + + if style.autoNightMode { + HStack { + Text("Light Threshold") + Spacer() + Slider(value: $style.ambientLightThreshold, in: 0.1...0.8) + Text("\(Int(style.ambientLightThreshold * 100))%") + .frame(width: 50, alignment: .trailing) + } + } + + Toggle("Scheduled Night Mode", isOn: $style.scheduledNightMode) + + if style.scheduledNightMode { + HStack { + Text("Start Time") + Spacer() + TimePickerView(timeString: $style.nightModeStartTime) + } + + HStack { + Text("End Time") + Spacer() + TimePickerView(timeString: $style.nightModeEndTime) + } + } + + if style.isNightModeActive { + HStack { + Image(systemName: "moon.fill") + .foregroundColor(.red) + Text("Night Mode Active") + .foregroundColor(.red) + Spacer() + } + } + } + } } private struct OverlaySection: View { @@ -199,12 +297,71 @@ private struct DisplaySection: View { Toggle("Keep Awake in Display Mode", isOn: $style.keepAwake) } + Section(header: Text("Auto Brightness"), footer: Text("Automatically adjust display brightness based on color theme and ambient light. Works with all color themes and night mode.")) { + Toggle("Auto Brightness", isOn: $style.autoBrightness) + + if style.autoBrightness { + HStack { + Text("Current Brightness") + Spacer() + Text("\(Int(style.effectiveBrightness * 100))%") + .foregroundColor(.secondary) + } + } + } + Section(header: Text("Focus Modes"), footer: Text("Control how the app behaves when Focus modes (Do Not Disturb) are active. When enabled, audio may be paused during Focus mode.")) { Toggle("Respect Focus Modes", isOn: $style.respectFocusModes) } } } +private struct TimePickerView: View { + @Binding var timeString: String + @State private var selectedTime = Date() + + var body: some View { + DatePicker("", selection: $selectedTime, displayedComponents: .hourAndMinute) + .labelsHidden() + .onAppear { + updateSelectedTimeFromString() + } + .onChange(of: selectedTime) { _, newTime in + updateStringFromTime(newTime) + } + .onChange(of: timeString) { _, _ in + updateSelectedTimeFromString() + } + } + + private func updateSelectedTimeFromString() { + let components = timeString.split(separator: ":") + guard components.count == 2, + let hour = Int(components[0]), + let minute = Int(components[1]) else { + return + } + + let calendar = Calendar.current + let now = Date() + let dateComponents = calendar.dateComponents([.year, .month, .day], from: now) + + var newComponents = dateComponents + newComponents.hour = hour + newComponents.minute = minute + + if let newDate = calendar.date(from: newComponents) { + selectedTime = newDate + } + } + + private func updateStringFromTime(_ time: Date) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + timeString = formatter.string(from: time) + } +} + // MARK: - Preview #Preview { ClockSettingsView( diff --git a/TheNoiseClock/Views/Clock/ClockView.swift b/TheNoiseClock/Views/Clock/ClockView.swift index 35f3c0e..22f1c5b 100644 --- a/TheNoiseClock/Views/Clock/ClockView.swift +++ b/TheNoiseClock/Views/Clock/ClockView.swift @@ -17,7 +17,7 @@ struct ClockView: View { // MARK: - Body var body: some View { ZStack { - viewModel.style.backgroundColor + viewModel.style.effectiveBackgroundColor .ignoresSafeArea() GeometryReader { geometry in diff --git a/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift b/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift index dcf26e1..ea081f6 100644 --- a/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift +++ b/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift @@ -34,7 +34,7 @@ struct ClockDisplayContainer: View { date: currentTime, use24Hour: style.use24Hour, showSeconds: style.showSeconds, - digitColor: style.digitColor, + digitColor: style.effectiveDigitColor, glowIntensity: style.glowIntensity, manualScale: style.digitScale, stretched: style.stretched,