fix: menubarextra shows update immediately

This commit is contained in:
Michael Freno
2026-01-09 19:41:49 -05:00
parent ec87520ba6
commit edf2d0115d
9 changed files with 172 additions and 30 deletions

View File

@@ -49,11 +49,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private func observeSettingsChanges() { private func observeSettingsChanges() {
settingsManager?.$settings settingsManager?.$settings
.sink { [weak self] settings in .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() self?.startTimers()
} else if self?.hasStartedTimers == true { } else if self?.hasStartedTimers == true {
// Restart timers when settings change (only if already started) print("📢 [AppDelegate] Restarting timers with new config")
self?.timerEngine?.start() // Defer timer restart to next runloop to ensure settings are fully propagated
DispatchQueue.main.async {
self?.timerEngine?.start()
}
} }
} }
.store(in: &cancellables) .store(in: &cancellables)

View File

@@ -11,6 +11,7 @@ import SwiftUI
struct GazeApp: App { struct GazeApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var settingsManager = SettingsManager.shared @StateObject private var settingsManager = SettingsManager.shared
@State private var menuBarRefreshID = 0
var body: some Scene { var body: some Scene {
// Onboarding window (only shown when not completed) // Onboarding window (only shown when not completed)
@@ -40,6 +41,7 @@ struct GazeApp: App {
// Menu bar extra (always present once onboarding is complete) // Menu bar extra (always present once onboarding is complete)
MenuBarExtra("Gaze", systemImage: "eye.fill") { MenuBarExtra("Gaze", systemImage: "eye.fill") {
if let timerEngine = appDelegate.timerEngine { if let timerEngine = appDelegate.timerEngine {
let _ = print("🔵 [GazeApp] MenuBarExtra body evaluated, refreshID: \(menuBarRefreshID)")
MenuBarContentView( MenuBarContentView(
timerEngine: timerEngine, timerEngine: timerEngine,
settingsManager: settingsManager, settingsManager: settingsManager,
@@ -47,9 +49,14 @@ struct GazeApp: App {
onOpenSettings: { appDelegate.openSettings() }, onOpenSettings: { appDelegate.openSettings() },
onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) } onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) }
) )
.id(menuBarRefreshID)
} }
} }
.menuBarExtraStyle(.window) .menuBarExtraStyle(.window)
.onChange(of: settingsManager.settings) { _ in
menuBarRefreshID += 1
print("🔵 [GazeApp] Settings changed, refreshID now: \(menuBarRefreshID)")
}
} }
private func closeAllWindows() { private func closeAllWindows() {
@@ -57,4 +64,4 @@ struct GazeApp: App {
window.close() window.close()
} }
} }
} }

View File

@@ -10,7 +10,7 @@ import Foundation
// MARK: - Centralized Configuration System // MARK: - Centralized Configuration System
/// Unified configuration class that manages all app settings in a centralized way /// Unified configuration class that manages all app settings in a centralized way
struct AppSettings: Codable, Equatable { struct AppSettings: Codable, Equatable, Hashable {
// Timer configurations // Timer configurations
var lookAwayTimer: TimerConfiguration var lookAwayTimer: TimerConfiguration
var lookAwayCountdownSeconds: Int var lookAwayCountdownSeconds: Int

View File

@@ -7,7 +7,7 @@
import Foundation import Foundation
struct TimerConfiguration: Codable, Equatable { struct TimerConfiguration: Codable, Equatable, Hashable {
var enabled: Bool var enabled: Bool
var intervalSeconds: Int var intervalSeconds: Int

View File

@@ -7,12 +7,13 @@
import Foundation import Foundation
struct TimerState: Equatable { struct TimerState: Equatable, Hashable {
let type: TimerType let type: TimerType
var remainingSeconds: Int var remainingSeconds: Int
var isPaused: Bool var isPaused: Bool
var isActive: Bool var isActive: Bool
var targetDate: Date var targetDate: Date
let originalIntervalSeconds: Int // Store original interval for comparison
init(type: TimerType, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) { init(type: TimerType, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) {
self.type = type self.type = type
@@ -20,6 +21,7 @@ struct TimerState: Equatable {
self.isPaused = isPaused self.isPaused = isPaused
self.isActive = isActive self.isActive = isActive
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds)) self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
self.originalIntervalSeconds = intervalSeconds
} }
static func == (lhs: TimerState, rhs: TimerState) -> Bool { static func == (lhs: TimerState, rhs: TimerState) -> Bool {
@@ -27,5 +29,6 @@ struct TimerState: Equatable {
&& lhs.isPaused == rhs.isPaused && lhs.isActive == rhs.isActive && lhs.isPaused == rhs.isPaused && lhs.isActive == rhs.isActive
&& lhs.targetDate.timeIntervalSince1970.rounded() && lhs.targetDate.timeIntervalSince1970.rounded()
== rhs.targetDate.timeIntervalSince1970.rounded() == rhs.targetDate.timeIntervalSince1970.rounded()
&& lhs.originalIntervalSeconds == rhs.originalIntervalSeconds
} }
} }

View File

@@ -9,7 +9,7 @@ import Foundation
import SwiftUI import SwiftUI
/// Represents a user-defined timer with customizable properties /// Represents a user-defined timer with customizable properties
struct UserTimer: Codable, Equatable, Identifiable { struct UserTimer: Codable, Equatable, Identifiable, Hashable {
let id: String let id: String
var title: String var title: String
var type: UserTimerType var type: UserTimerType

View File

@@ -24,19 +24,38 @@ class TimerEngine: ObservableObject {
} }
func start() { 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() stop()
var newStates: [TimerType: TimerState] = [:]
for timerType in TimerType.allCases { for timerType in TimerType.allCases {
let config = settingsManager.timerConfiguration(for: timerType) let config = settingsManager.timerConfiguration(for: timerType)
if config.enabled { if config.enabled {
timerStates[timerType] = TimerState( newStates[timerType] = TimerState(
type: timerType, type: timerType,
intervalSeconds: config.intervalSeconds, intervalSeconds: config.intervalSeconds,
isPaused: false, isPaused: false,
isActive: true 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 // Start user timers
for userTimer in settingsManager.settings.userTimers { 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() { func stop() {
timerSubscription?.cancel() timerSubscription?.cancel()

View File

@@ -50,6 +50,9 @@ struct MenuBarContentView: View {
var onQuit: () -> Void var onQuit: () -> Void
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenSettingsTab: (Int) -> Void var onOpenSettingsTab: (Int) -> Void
// Force view refresh when timer states change
@State private var refreshID = UUID()
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -167,9 +170,13 @@ struct MenuBarContentView: View {
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.frame(width: 300) .frame(width: 300)
.id(refreshID)
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("CloseMenuBarPopover"))) { _ in .onReceive(NotificationCenter.default.publisher(for: Notification.Name("CloseMenuBarPopover"))) { _ in
dismiss() dismiss()
} }
.onReceive(timerEngine.$timerStates) { _ in
refreshID = UUID()
}
} }
private var isPaused: Bool { private var isPaused: Bool {

View File

@@ -120,28 +120,38 @@ struct SettingsWindowView: View {
} }
private func applySettings() { private func applySettings() {
settingsManager.settings.lookAwayTimer = TimerConfiguration( print("🔧 [SettingsWindow] Applying settings...")
enabled: lookAwayEnabled,
intervalSeconds: lookAwayIntervalMinutes * 60 // 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
print("🔧 [SettingsWindow] Old settings - Blink: \(settingsManager.settings.blinkTimer.enabled), interval: \(settingsManager.settings.blinkTimer.intervalSeconds)")
settingsManager.settings.blinkTimer = TimerConfiguration( print("🔧 [SettingsWindow] New settings - Blink: \(updatedSettings.blinkTimer.enabled), interval: \(updatedSettings.blinkTimer.intervalSeconds)")
enabled: blinkEnabled,
intervalSeconds: blinkIntervalMinutes * 60 // Assign the entire settings object to trigger didSet and observers
) settingsManager.settings = updatedSettings
settingsManager.settings.postureTimer = TimerConfiguration( print("🔧 [SettingsWindow] Settings assigned to manager")
enabled: postureEnabled,
intervalSeconds: postureIntervalMinutes * 60
)
settingsManager.settings.launchAtLogin = launchAtLogin
settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage
settingsManager.settings.userTimers = userTimers
// Save settings to persist changes
settingsManager.save()
do { do {
if launchAtLogin { if launchAtLogin {