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 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 &&

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

View File

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

View File

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

View File

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