TheNoiseClock/TheNoiseClock/Features/Clock/State/ClockViewModel.swift

289 lines
10 KiB
Swift

//
// ClockViewModel.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import Foundation
import Combine
import Observation
import AudioPlaybackKit
import SwiftUI
import Bedrock
/// ViewModel for clock display and management
@Observable
class ClockViewModel {
// MARK: - Properties
private(set) var currentTime = Date()
private(set) var style = ClockStyle()
private(set) var isDisplayMode = false
// 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?
private var secondCancellable: AnyCancellable?
private var minuteCancellable: AnyCancellable?
private var styleObserver: NSObjectProtocol?
// Persistence
private var persistenceWorkItem: DispatchWorkItem?
private var styleJSON: Data {
get {
UserDefaults.standard.data(forKey: ClockStyle.appStorageKey) ?? {
let def = ClockStyle()
return (try? JSONEncoder().encode(def)) ?? Data()
}()
}
set {
UserDefaults.standard.set(newValue, forKey: ClockStyle.appStorageKey)
}
}
// MARK: - Initialization
init() {
loadStyle()
setupTimers()
startAmbientLightMonitoring()
observeStyleUpdates()
}
deinit {
stopTimers()
stopAmbientLightMonitoring()
if let styleObserver {
NotificationCenter.default.removeObserver(styleObserver)
}
}
// MARK: - Public Interface
func toggleDisplayMode() {
let oldValue = isDisplayMode
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
isDisplayMode.toggle()
}
Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)")
// Manage wake lock based on display mode and keep awake setting
updateWakeLockState()
if isDisplayMode {
requestKeepAwakePromptIfNeeded()
}
}
func setDisplayMode(_ enabled: Bool) {
guard isDisplayMode != enabled else {
Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping")
return
}
Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)")
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
isDisplayMode = enabled
}
updateWakeLockState()
if enabled {
requestKeepAwakePromptIfNeeded()
}
}
func updateStyle(_ newStyle: ClockStyle) {
// Update properties of the existing style object instead of replacing it
// This preserves the @Observable chain
style.use24Hour = newStyle.use24Hour
style.showSeconds = newStyle.showSeconds
style.showAmPm = newStyle.showAmPm
style.forceHorizontalMode = newStyle.forceHorizontalMode
style.digitColorHex = newStyle.digitColorHex
style.glowIntensity = newStyle.glowIntensity
style.clockOpacity = newStyle.clockOpacity
style.fontFamily = newStyle.fontFamily
style.fontWeight = newStyle.fontWeight
style.fontDesign = newStyle.fontDesign
style.showBattery = newStyle.showBattery
style.showDate = newStyle.showDate
style.overlayOpacity = newStyle.overlayOpacity
style.backgroundHex = newStyle.backgroundHex
style.keepAwake = newStyle.keepAwake
style.randomizeColor = newStyle.randomizeColor
style.selectedColorTheme = newStyle.selectedColorTheme
style.nightModeEnabled = newStyle.nightModeEnabled
style.autoNightMode = newStyle.autoNightMode
style.scheduledNightMode = newStyle.scheduledNightMode
style.nightModeStartTime = newStyle.nightModeStartTime
style.nightModeEndTime = newStyle.nightModeEndTime
style.ambientLightThreshold = newStyle.ambientLightThreshold
style.autoBrightness = newStyle.autoBrightness
style.digitAnimationStyle = newStyle.digitAnimationStyle
style.dateFormat = newStyle.dateFormat
style.respectFocusModes = newStyle.respectFocusModes
style.liveActivitiesEnabled = newStyle.liveActivitiesEnabled
saveStyle()
updateTimersIfNeeded()
updateWakeLockState()
updateBrightness() // Update brightness when style changes
}
func setKeepAwakeEnabled(_ enabled: Bool) {
style.keepAwake = enabled
saveStyle()
updateWakeLockState()
}
// MARK: - Private Methods
private func loadStyle() {
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
style = decoded
} else {
style = ClockStyle()
saveStyle()
}
}
private func observeStyleUpdates() {
styleObserver = NotificationCenter.default.addObserver(
forName: .clockStyleDidUpdate,
object: nil,
queue: .main
) { [weak self] _ in
self?.loadStyle()
self?.updateTimersIfNeeded()
self?.updateWakeLockState()
self?.updateBrightness()
}
}
func saveStyle() {
persistenceWorkItem?.cancel()
let work = DispatchWorkItem {
if let data = try? JSONEncoder().encode(self.style) {
self.styleJSON = data
}
}
persistenceWorkItem = work
DispatchQueue.main.asyncAfter(
deadline: .now() + AppConstants.PersistenceDelays.clockStyle,
execute: work
)
}
private func setupTimers() {
// Always need minute timer for color randomization
if minuteTimer == nil {
minuteTimer = Timer.publish(every: AppConstants.TimerIntervals.minute, on: .main, in: .common)
minuteCancellable = minuteTimer?.autoconnect().sink { _ in
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()
}
}
// Only create second timer if seconds are shown
if style.showSeconds && secondTimer == nil {
secondTimer = Timer.publish(every: AppConstants.TimerIntervals.second, on: .main, in: .common)
secondCancellable = secondTimer?.autoconnect().sink { now in
self.currentTime = now
}
}
}
private func stopTimers() {
secondCancellable?.cancel()
minuteCancellable?.cancel()
secondCancellable = nil
minuteCancellable = nil
secondTimer = nil
minuteTimer = nil
}
private func updateTimersIfNeeded() {
if style.showSeconds && secondTimer == nil {
secondTimer = Timer.publish(every: AppConstants.TimerIntervals.second, on: .main, in: .common)
secondCancellable = secondTimer?.autoconnect().sink { now in
self.currentTime = now
}
} else if !style.showSeconds && secondTimer != nil {
secondCancellable?.cancel()
secondCancellable = nil
secondTimer = nil
}
}
private func requestKeepAwakePromptIfNeeded() {
guard !style.keepAwake else { return }
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
}
/// Update wake lock state based on current settings
private func updateWakeLockState() {
// Enable wake lock if in display mode and keep awake is enabled
if isDisplayMode && style.keepAwake {
wakeLockService.enableWakeLock()
} else {
wakeLockService.disableWakeLock()
}
}
/// Start ambient light monitoring
private func startAmbientLightMonitoring() {
ambientLightService.startMonitoring()
// Set up callback to respond to brightness changes
ambientLightService.onBrightnessChange = { [weak self] in
//Design.debugLog("[brightness] ClockViewModel: Received brightness change notification")
self?.updateBrightness()
}
}
/// 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
let currentScreenBrightness = UIScreen.main.brightness
let isNightMode = style.isNightModeActive
// Design.debugLog("[brightness] Auto Brightness Debug:")
// Design.debugLog("[brightness] - Auto brightness enabled: \(style.autoBrightness)")
// Design.debugLog("[brightness] - Current screen brightness: \(String(format: "%.2f", currentScreenBrightness))")
// Design.debugLog("[brightness] - Target brightness: \(String(format: "%.2f", targetBrightness))")
// Design.debugLog("[brightness] - Night mode active: \(isNightMode)")
// Design.debugLog("[brightness] - Color theme: \(style.selectedColorTheme)")
// Design.debugLog("[brightness] - Ambient light threshold: \(String(format: "%.2f", style.ambientLightThreshold))")
ambientLightService.setBrightness(targetBrightness)
// Design.debugLog("[brightness] - Brightness set to: \(String(format: "%.2f", targetBrightness))")
// Design.debugLog("[brightness] - Actual screen brightness now: \(String(format: "%.2f", UIScreen.main.brightness))")
// Design.debugLog("[brightness] ---")
// } else {
// Design.debugLog("[brightness] Auto Brightness: DISABLED")
}
}
}