Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-09-10 12:44:47 -05:00
parent 267f3ac987
commit 05cd4f10e6
6 changed files with 532 additions and 2 deletions

View File

@ -24,6 +24,18 @@ class ClockStyle: Codable, Equatable {
var stretched: Bool = true var stretched: Bool = true
var backgroundHex: String = AppConstants.Defaults.backgroundColorHex 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 // MARK: - Font Settings
var fontFamily: String = "System" // System, San Francisco, etc. var fontFamily: String = "System" // System, San Francisco, etc.
var fontWeight: String = "Bold" // Ultra Light, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black 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 digitScale
case stretched case stretched
case backgroundHex case backgroundHex
case selectedColorTheme
case nightModeEnabled
case autoNightMode
case scheduledNightMode
case nightModeStartTime
case nightModeEndTime
case autoBrightness
case ambientLightThreshold
case fontFamily case fontFamily
case fontWeight case fontWeight
case fontDesign case fontDesign
@ -83,6 +103,14 @@ class ClockStyle: Codable, Equatable {
self.digitScale = try container.decodeIfPresent(Double.self, forKey: .digitScale) ?? self.digitScale self.digitScale = try container.decodeIfPresent(Double.self, forKey: .digitScale) ?? self.digitScale
self.stretched = try container.decodeIfPresent(Bool.self, forKey: .stretched) ?? self.stretched self.stretched = try container.decodeIfPresent(Bool.self, forKey: .stretched) ?? self.stretched
self.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex 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.fontFamily = try container.decodeIfPresent(String.self, forKey: .fontFamily) ?? self.fontFamily
self.fontWeight = try container.decodeIfPresent(String.self, forKey: .fontWeight) ?? self.fontWeight self.fontWeight = try container.decodeIfPresent(String.self, forKey: .fontWeight) ?? self.fontWeight
self.fontDesign = try container.decodeIfPresent(String.self, forKey: .fontDesign) ?? self.fontDesign 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(digitScale, forKey: .digitScale)
try container.encode(stretched, forKey: .stretched) try container.encode(stretched, forKey: .stretched)
try container.encode(backgroundHex, forKey: .backgroundHex) 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(fontFamily, forKey: .fontFamily)
try container.encode(fontWeight, forKey: .fontWeight) try container.encode(fontWeight, forKey: .fontWeight)
try container.encode(fontDesign, forKey: .fontDesign) try container.encode(fontDesign, forKey: .fontDesign)
@ -144,6 +180,227 @@ class ClockStyle: Codable, Equatable {
_cachedBackgroundColor = nil _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 // MARK: - Equatable
static func == (lhs: ClockStyle, rhs: ClockStyle) -> Bool { static func == (lhs: ClockStyle, rhs: ClockStyle) -> Bool {
lhs.use24Hour == rhs.use24Hour && lhs.use24Hour == rhs.use24Hour &&
@ -154,6 +411,14 @@ class ClockStyle: Codable, Equatable {
lhs.digitScale == rhs.digitScale && lhs.digitScale == rhs.digitScale &&
lhs.stretched == rhs.stretched && lhs.stretched == rhs.stretched &&
lhs.backgroundHex == rhs.backgroundHex && 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.fontFamily == rhs.fontFamily &&
lhs.fontWeight == rhs.fontWeight && lhs.fontWeight == rhs.fontWeight &&
lhs.fontDesign == rhs.fontDesign && lhs.fontDesign == rhs.fontDesign &&

View File

@ -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
}
}

View File

@ -23,6 +23,9 @@ class ClockViewModel {
// Wake lock service // Wake lock service
private let wakeLockService = WakeLockService.shared private let wakeLockService = WakeLockService.shared
// Ambient light service
private let ambientLightService = AmbientLightService.shared
// Timer management // Timer management
private var secondTimer: Timer.TimerPublisher? private var secondTimer: Timer.TimerPublisher?
private var minuteTimer: Timer.TimerPublisher? private var minuteTimer: Timer.TimerPublisher?
@ -47,10 +50,12 @@ class ClockViewModel {
init() { init() {
loadStyle() loadStyle()
setupTimers() setupTimers()
startAmbientLightMonitoring()
} }
deinit { deinit {
stopTimers() stopTimers()
stopAmbientLightMonitoring()
} }
// MARK: - Public Interface // MARK: - Public Interface
@ -68,6 +73,7 @@ class ClockViewModel {
saveStyle() saveStyle()
updateTimersIfNeeded() updateTimersIfNeeded()
updateWakeLockState() updateWakeLockState()
updateBrightness() // Update brightness when style changes
} }
// MARK: - Private Methods // MARK: - Private Methods
@ -104,7 +110,15 @@ class ClockViewModel {
if self.style.randomizeColor { if self.style.randomizeColor {
self.style.digitColorHex = Color.randomBrightColorHex() self.style.digitColorHex = Color.randomBrightColorHex()
self.saveStyle() 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() 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)
}
}
} }

View File

@ -35,6 +35,7 @@ struct ClockSettingsView: View {
backgroundColor: $backgroundColor, backgroundColor: $backgroundColor,
onCommit: onCommit onCommit: onCommit
) )
NightModeSection(style: $style)
DisplaySection(style: $style) DisplaySection(style: $style)
OverlaySection(style: $style) OverlaySection(style: $style)
} }
@ -122,6 +123,27 @@ private struct AppearanceSection: View {
var body: some View { var body: some View {
Section(header: Text("Appearance")) { 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("Background Color", selection: $backgroundColor, supportsOpacity: true)
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false) ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
Toggle("Randomize Color (every minute)", isOn: $style.randomizeColor) Toggle("Randomize Color (every minute)", isOn: $style.randomizeColor)
@ -153,13 +175,89 @@ private struct AppearanceSection: View {
} }
.onChange(of: backgroundColor) { _, newValue in .onChange(of: backgroundColor) { _, newValue in
style.backgroundHex = newValue.toHex() ?? AppConstants.Defaults.backgroundColorHex style.backgroundHex = newValue.toHex() ?? AppConstants.Defaults.backgroundColorHex
style.selectedColorTheme = "Custom"
style.clearColorCache() style.clearColorCache()
} }
.onChange(of: digitColor) { _, newValue in .onChange(of: digitColor) { _, newValue in
style.digitColorHex = newValue.toHex() ?? AppConstants.Defaults.digitColorHex style.digitColorHex = newValue.toHex() ?? AppConstants.Defaults.digitColorHex
style.selectedColorTheme = "Custom"
style.clearColorCache() 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 { private struct OverlaySection: View {
@ -199,12 +297,71 @@ private struct DisplaySection: View {
Toggle("Keep Awake in Display Mode", isOn: $style.keepAwake) 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.")) { 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) 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 // MARK: - Preview
#Preview { #Preview {
ClockSettingsView( ClockSettingsView(

View File

@ -17,7 +17,7 @@ struct ClockView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
ZStack { ZStack {
viewModel.style.backgroundColor viewModel.style.effectiveBackgroundColor
.ignoresSafeArea() .ignoresSafeArea()
GeometryReader { geometry in GeometryReader { geometry in

View File

@ -34,7 +34,7 @@ struct ClockDisplayContainer: View {
date: currentTime, date: currentTime,
use24Hour: style.use24Hour, use24Hour: style.use24Hour,
showSeconds: style.showSeconds, showSeconds: style.showSeconds,
digitColor: style.digitColor, digitColor: style.effectiveDigitColor,
glowIntensity: style.glowIntensity, glowIntensity: style.glowIntensity,
manualScale: style.digitScale, manualScale: style.digitScale,
stretched: style.stretched, stretched: style.stretched,