diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index fb9d40b..d2a8138 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -49,11 +49,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func observeSettingsChanges() { settingsManager?.$settings .sink { [weak self] settings in - if settings.hasCompletedOnboarding { + print("📢 [AppDelegate] Settings changed!") + if settings.hasCompletedOnboarding && self?.hasStartedTimers == false { + print("📢 [AppDelegate] Starting timers for first time") self?.startTimers() } else if self?.hasStartedTimers == true { - // Restart timers when settings change (only if already started) - self?.timerEngine?.start() + print("📢 [AppDelegate] Restarting timers with new config") + // Defer timer restart to next runloop to ensure settings are fully propagated + DispatchQueue.main.async { + self?.timerEngine?.start() + } } } .store(in: &cancellables) diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index 3fb2216..316298a 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -11,6 +11,7 @@ import SwiftUI struct GazeApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var settingsManager = SettingsManager.shared + @State private var menuBarRefreshID = 0 var body: some Scene { // Onboarding window (only shown when not completed) @@ -40,6 +41,7 @@ struct GazeApp: App { // Menu bar extra (always present once onboarding is complete) MenuBarExtra("Gaze", systemImage: "eye.fill") { if let timerEngine = appDelegate.timerEngine { + let _ = print("🔵 [GazeApp] MenuBarExtra body evaluated, refreshID: \(menuBarRefreshID)") MenuBarContentView( timerEngine: timerEngine, settingsManager: settingsManager, @@ -47,9 +49,14 @@ struct GazeApp: App { onOpenSettings: { appDelegate.openSettings() }, onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) } ) + .id(menuBarRefreshID) } } .menuBarExtraStyle(.window) + .onChange(of: settingsManager.settings) { _ in + menuBarRefreshID += 1 + print("🔵 [GazeApp] Settings changed, refreshID now: \(menuBarRefreshID)") + } } private func closeAllWindows() { @@ -57,4 +64,4 @@ struct GazeApp: App { window.close() } } -} +} \ No newline at end of file diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift index 4744165..c5a32c4 100644 --- a/Gaze/Models/AppSettings.swift +++ b/Gaze/Models/AppSettings.swift @@ -10,7 +10,7 @@ import Foundation // MARK: - Centralized Configuration System /// Unified configuration class that manages all app settings in a centralized way -struct AppSettings: Codable, Equatable { +struct AppSettings: Codable, Equatable, Hashable { // Timer configurations var lookAwayTimer: TimerConfiguration var lookAwayCountdownSeconds: Int diff --git a/Gaze/Models/TimerConfiguration.swift b/Gaze/Models/TimerConfiguration.swift index 4ca1c79..4fca85e 100644 --- a/Gaze/Models/TimerConfiguration.swift +++ b/Gaze/Models/TimerConfiguration.swift @@ -7,7 +7,7 @@ import Foundation -struct TimerConfiguration: Codable, Equatable { +struct TimerConfiguration: Codable, Equatable, Hashable { var enabled: Bool var intervalSeconds: Int diff --git a/Gaze/Models/TimerState.swift b/Gaze/Models/TimerState.swift index e257975..a16f977 100644 --- a/Gaze/Models/TimerState.swift +++ b/Gaze/Models/TimerState.swift @@ -7,12 +7,13 @@ import Foundation -struct TimerState: Equatable { +struct TimerState: Equatable, Hashable { let type: TimerType var remainingSeconds: Int var isPaused: Bool var isActive: Bool var targetDate: Date + let originalIntervalSeconds: Int // Store original interval for comparison init(type: TimerType, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) { self.type = type @@ -20,6 +21,7 @@ struct TimerState: Equatable { self.isPaused = isPaused self.isActive = isActive self.targetDate = Date().addingTimeInterval(Double(intervalSeconds)) + self.originalIntervalSeconds = intervalSeconds } static func == (lhs: TimerState, rhs: TimerState) -> Bool { @@ -27,5 +29,6 @@ struct TimerState: Equatable { && lhs.isPaused == rhs.isPaused && lhs.isActive == rhs.isActive && lhs.targetDate.timeIntervalSince1970.rounded() == rhs.targetDate.timeIntervalSince1970.rounded() + && lhs.originalIntervalSeconds == rhs.originalIntervalSeconds } } diff --git a/Gaze/Models/UserTimer.swift b/Gaze/Models/UserTimer.swift index 60a94dd..188038a 100644 --- a/Gaze/Models/UserTimer.swift +++ b/Gaze/Models/UserTimer.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI /// Represents a user-defined timer with customizable properties -struct UserTimer: Codable, Equatable, Identifiable { +struct UserTimer: Codable, Equatable, Identifiable, Hashable { let id: String var title: String var type: UserTimerType diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index c51a83f..35ec1cb 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -24,19 +24,38 @@ class TimerEngine: ObservableObject { } func start() { + print("🎯 [TimerEngine] start() called, subscription exists: \(timerSubscription != nil)") + + // If timers are already running, just update configurations without resetting + if timerSubscription != nil { + print("🎯 [TimerEngine] Updating existing configurations") + updateConfigurations() + return + } + + print("🎯 [TimerEngine] Initial start - creating all timer states") + + // Initial start - create all timer states stop() + var newStates: [TimerType: TimerState] = [:] + for timerType in TimerType.allCases { let config = settingsManager.timerConfiguration(for: timerType) if config.enabled { - timerStates[timerType] = TimerState( + newStates[timerType] = TimerState( type: timerType, intervalSeconds: config.intervalSeconds, isPaused: false, isActive: true ) + print("🎯 [TimerEngine] Created state for \(timerType.displayName): \(config.intervalSeconds)s") } } + + // Assign the entire dictionary at once to trigger @Published + timerStates = newStates + print("🎯 [TimerEngine] Assigned \(newStates.count) timer states") // Start user timers for userTimer in settingsManager.settings.userTimers { @@ -51,6 +70,97 @@ class TimerEngine: ObservableObject { } } } + + private func updateConfigurations() { + print("🔄 [TimerEngine] updateConfigurations() called") + print("🔄 [TimerEngine] Current timerStates keys: \(timerStates.keys.map { $0.displayName })") + var newStates: [TimerType: TimerState] = [:] + + for timerType in TimerType.allCases { + let config = settingsManager.timerConfiguration(for: timerType) + print("🔄 [TimerEngine] Processing \(timerType.displayName): enabled=\(config.enabled), intervalSeconds=\(config.intervalSeconds)") + + if config.enabled { + if let existingState = timerStates[timerType] { + // Timer exists - check if interval changed + print("🔄 [TimerEngine] \(timerType.displayName) exists in current states") + if existingState.originalIntervalSeconds != config.intervalSeconds { + // Interval changed - reset with new interval + print("🔄 [TimerEngine] \(timerType.displayName) interval changed: \(existingState.originalIntervalSeconds)s -> \(config.intervalSeconds)s, resetting") + newStates[timerType] = TimerState( + type: timerType, + intervalSeconds: config.intervalSeconds, + isPaused: existingState.isPaused, + isActive: true + ) + } else { + // Interval unchanged - keep existing state + print("🔄 [TimerEngine] \(timerType.displayName) unchanged, keeping state (remaining: \(existingState.remainingSeconds)s)") + newStates[timerType] = existingState + } + } else { + // Timer was just enabled - create new state + print("🔄 [TimerEngine] \(timerType.displayName) NOT in current states, newly enabled, creating state") + newStates[timerType] = TimerState( + type: timerType, + intervalSeconds: config.intervalSeconds, + isPaused: false, + isActive: true + ) + } + } else { + if timerStates[timerType] != nil { + print("🔄 [TimerEngine] \(timerType.displayName) disabled, removing state") + } else { + print("🔄 [TimerEngine] \(timerType.displayName) disabled and not in current states") + } + } + // If config.enabled is false and timer exists, it will be removed + } + + print("🔄 [TimerEngine] New states keys: \(newStates.keys.map { $0.displayName })") + print("🔄 [TimerEngine] Assigning \(newStates.count) timer states (was \(timerStates.count))") + // Assign the entire dictionary at once to trigger @Published + timerStates = newStates + + // Update user timers + updateUserTimers() + } + + private func updateUserTimers() { + let currentTimerIds = Set(userTimerStates.keys) + let newTimerIds = Set(settingsManager.settings.userTimers.map { $0.id }) + + // Remove timers that no longer exist + let removedIds = currentTimerIds.subtracting(newTimerIds) + for id in removedIds { + userTimerStates.removeValue(forKey: id) + } + + // Add or update timers + for userTimer in settingsManager.settings.userTimers { + if let existingState = userTimerStates[userTimer.id] { + // Check if interval changed + if existingState.originalIntervalSeconds != userTimer.timeOnScreenSeconds { + // Interval changed - reset with new interval + userTimerStates[userTimer.id] = TimerState( + type: .lookAway, // Placeholder + intervalSeconds: userTimer.timeOnScreenSeconds, + isPaused: existingState.isPaused, + isActive: userTimer.enabled + ) + } else { + // Just update enabled state if needed + var state = existingState + state.isActive = userTimer.enabled + userTimerStates[userTimer.id] = state + } + } else { + // New timer - create state + startUserTimer(userTimer) + } + } + } func stop() { timerSubscription?.cancel() diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index 8336fed..2c4f138 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -50,6 +50,9 @@ struct MenuBarContentView: View { var onQuit: () -> Void var onOpenSettings: () -> Void var onOpenSettingsTab: (Int) -> Void + + // Force view refresh when timer states change + @State private var refreshID = UUID() var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -167,9 +170,13 @@ struct MenuBarContentView: View { .padding(.vertical, 8) } .frame(width: 300) + .id(refreshID) .onReceive(NotificationCenter.default.publisher(for: Notification.Name("CloseMenuBarPopover"))) { _ in dismiss() } + .onReceive(timerEngine.$timerStates) { _ in + refreshID = UUID() + } } private var isPaused: Bool { diff --git a/Gaze/Views/SettingsWindowView.swift b/Gaze/Views/SettingsWindowView.swift index 1469f84..cc7ce11 100644 --- a/Gaze/Views/SettingsWindowView.swift +++ b/Gaze/Views/SettingsWindowView.swift @@ -120,28 +120,38 @@ struct SettingsWindowView: View { } private func applySettings() { - settingsManager.settings.lookAwayTimer = TimerConfiguration( - enabled: lookAwayEnabled, - intervalSeconds: lookAwayIntervalMinutes * 60 + print("🔧 [SettingsWindow] Applying settings...") + + // Create a new AppSettings object with updated values + // This triggers the didSet observer in SettingsManager + let updatedSettings = AppSettings( + lookAwayTimer: TimerConfiguration( + enabled: lookAwayEnabled, + intervalSeconds: lookAwayIntervalMinutes * 60 + ), + lookAwayCountdownSeconds: lookAwayCountdownSeconds, + blinkTimer: TimerConfiguration( + enabled: blinkEnabled, + intervalSeconds: blinkIntervalMinutes * 60 + ), + postureTimer: TimerConfiguration( + enabled: postureEnabled, + intervalSeconds: postureIntervalMinutes * 60 + ), + userTimers: userTimers, + subtleReminderSizePercentage: subtleReminderSizePercentage, + hasCompletedOnboarding: settingsManager.settings.hasCompletedOnboarding, + launchAtLogin: launchAtLogin, + playSounds: settingsManager.settings.playSounds ) - settingsManager.settings.lookAwayCountdownSeconds = lookAwayCountdownSeconds - - settingsManager.settings.blinkTimer = TimerConfiguration( - enabled: blinkEnabled, - intervalSeconds: blinkIntervalMinutes * 60 - ) - - settingsManager.settings.postureTimer = TimerConfiguration( - enabled: postureEnabled, - intervalSeconds: postureIntervalMinutes * 60 - ) - - settingsManager.settings.launchAtLogin = launchAtLogin - settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage - settingsManager.settings.userTimers = userTimers - - // Save settings to persist changes - settingsManager.save() + + print("🔧 [SettingsWindow] Old settings - Blink: \(settingsManager.settings.blinkTimer.enabled), interval: \(settingsManager.settings.blinkTimer.intervalSeconds)") + print("🔧 [SettingsWindow] New settings - Blink: \(updatedSettings.blinkTimer.enabled), interval: \(updatedSettings.blinkTimer.intervalSeconds)") + + // Assign the entire settings object to trigger didSet and observers + settingsManager.settings = updatedSettings + + print("🔧 [SettingsWindow] Settings assigned to manager") do { if launchAtLogin {