From a364cad05ceec663ac679cc5ff67575e9f7a0cf1 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 12 Jan 2026 00:40:04 -0500 Subject: [PATCH] general: sanity improvements --- Gaze.xcodeproj/project.pbxproj | 8 +- Gaze/AppDelegate.swift | 140 +++---- Gaze/Constants/WindowIdentifiers.swift | 15 + Gaze/Extensions/TimeIntervalExtensions.swift | 56 +++ Gaze/Models/AppSettings.swift | 8 +- Gaze/Models/ReminderEvent.swift | 13 + Gaze/Models/TimerConfiguration.swift | 4 - Gaze/Models/TimerIdentifier.swift | 33 ++ Gaze/Models/TimerState.swift | 14 +- Gaze/Models/UserTimer.swift | 6 - Gaze/Services/SettingsManager.swift | 77 +++- Gaze/Services/TimerEngine.swift | 383 ++++++++----------- Gaze/Views/MenuBar/MenuBarContentView.swift | 202 +++++----- 13 files changed, 502 insertions(+), 457 deletions(-) create mode 100644 Gaze/Constants/WindowIdentifiers.swift create mode 100644 Gaze/Extensions/TimeIntervalExtensions.swift create mode 100644 Gaze/Models/TimerIdentifier.swift diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj index 210652d..1a6740a 100644 --- a/Gaze.xcodeproj/project.pbxproj +++ b/Gaze.xcodeproj/project.pbxproj @@ -424,7 +424,7 @@ CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6GK4F9L62V; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -438,7 +438,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 0.2.3; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -460,7 +460,7 @@ CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6GK4F9L62V; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -474,7 +474,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 0.2.3; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 68cb8d1..15adf6d 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -12,36 +12,34 @@ import Combine @MainActor class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @Published var timerEngine: TimerEngine? - private var settingsManager: SettingsManager? + private let settingsManager: SettingsManager = .shared private var updateManager: UpdateManager? private var reminderWindowController: NSWindowController? private var settingsWindowController: NSWindowController? private var cancellables = Set() - private var timerStateBeforeSleep: [TimerType: Date] = [:] private var hasStartedTimers = false func applicationDidFinishLaunching(_ notification: Notification) { // Set activation policy to hide dock icon NSApplication.shared.setActivationPolicy(.accessory) - settingsManager = SettingsManager.shared - timerEngine = TimerEngine(settingsManager: settingsManager!) + timerEngine = TimerEngine(settingsManager: settingsManager) // Initialize update manager after onboarding is complete - if settingsManager!.settings.hasCompletedOnboarding { + if settingsManager.settings.hasCompletedOnboarding { updateManager = UpdateManager.shared } // Detect App Store version asynchronously at launch Task { - await settingsManager?.detectAppStoreVersion() + await settingsManager.detectAppStoreVersion() } setupLifecycleObservers() observeSettingsChanges() // Start timers if onboarding is complete - if settingsManager!.settings.hasCompletedOnboarding { + if settingsManager.settings.hasCompletedOnboarding { startTimers() } } @@ -63,7 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } private func observeSettingsChanges() { - settingsManager?.$settings + settingsManager.$settings .sink { [weak self] settings in if settings.hasCompletedOnboarding && self?.hasStartedTimers == false { self?.startTimers() @@ -78,7 +76,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } func applicationWillTerminate(_ notification: Notification) { - settingsManager?.save() + settingsManager.save() timerEngine?.stop() } @@ -99,38 +97,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } @objc private func systemWillSleep() { - // Save timer states - if let timerEngine = timerEngine { - for (type, state) in timerEngine.timerStates { - if state.isActive && !state.isPaused { - timerStateBeforeSleep[type] = Date() - } - } - } - timerEngine?.pause() - settingsManager?.save() + timerEngine?.handleSystemSleep() + settingsManager.save() } @objc private func systemDidWake() { - guard let timerEngine = timerEngine else { return } - - let now = Date() - for (type, sleepTime) in timerStateBeforeSleep { - let elapsed = Int(now.timeIntervalSince(sleepTime)) - - if var state = timerEngine.timerStates[type] { - state.remainingSeconds = max(0, state.remainingSeconds - elapsed) - timerEngine.timerStates[type] = state - - // If timer expired during sleep, trigger it now - if state.remainingSeconds <= 0 { - timerEngine.timerStates[type]?.remainingSeconds = 1 - } - } - } - - timerStateBeforeSleep.removeAll() - timerEngine.resume() + timerEngine?.handleSystemWake() } private func observeReminderEvents() { @@ -156,14 +128,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } ) case .blinkTriggered: - let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0 + let sizePercentage = settingsManager.settings.subtleReminderSize.percentage contentView = AnyView( BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in self?.timerEngine?.dismissReminder() } ) case .postureTriggered: - let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0 + let sizePercentage = settingsManager.settings.subtleReminderSize.percentage contentView = AnyView( PostureReminderView(sizePercentage: sizePercentage) { [weak self] in self?.timerEngine?.dismissReminder() @@ -177,7 +149,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } ) } else { - let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0 + let sizePercentage = settingsManager.settings.subtleReminderSize.percentage contentView = AnyView( UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { [weak self] in self?.timerEngine?.dismissReminder() @@ -199,18 +171,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { defer: false ) + window.identifier = WindowIdentifiers.reminder window.level = .floating window.isOpaque = false window.backgroundColor = .clear window.contentView = NSHostingView(rootView: content) window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - // Ensure this window can receive key events window.acceptsMouseMovedEvents = true window.makeFirstResponder(window.contentView) let windowController = NSWindowController(window: window) windowController.showWindow(nil) - // Make sure the window is brought to front and made key for key events window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) @@ -235,56 +206,38 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { // Public method to reopen onboarding window func openOnboarding() { - // Post notification to close menu bar popover NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) - // Small delay to allow menu bar to close before opening onboarding DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - guard let self = self, let settingsManager = self.settingsManager else { return } + guard let self = self else { return } -// Check if onboarding window already exists from the WindowGroup - let existingWindow = NSApplication.shared.windows.first { window in - // Check if window contains OnboardingContainerView by examining its content view - if window.contentView is NSHostingView { - return true - } - // Also check for windows with our expected size (onboarding window dimensions) - return window.frame.size.width == 700 && window.frame.size.height == 700 - && window.styleMask.contains(.titled) - && window.title.isEmpty // WindowGroup windows have empty title by default - } - - if let window = existingWindow { - // Reuse existing window - just bring it to front - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } else { - // Create new window matching WindowGroup style - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), - styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - - // Match the WindowGroup style: hiddenTitleBar - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.center() - window.isReleasedWhenClosed = true - window.contentView = NSHostingView( - rootView: OnboardingContainerView(settingsManager: settingsManager) - ) - - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) + if self.activateWindow(withIdentifier: WindowIdentifiers.onboarding) { + return } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), + styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.identifier = WindowIdentifiers.onboarding + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.center() + window.isReleasedWhenClosed = true + window.contentView = NSHostingView( + rootView: OnboardingContainerView(settingsManager: self.settingsManager) + ) + + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) } } private func openSettingsWindow(tab: Int) { - // If window already exists, switch to the tab and bring it to front - if let existingWindow = settingsWindowController?.window { + if let existingWindow = findWindow(withIdentifier: WindowIdentifiers.settings) { NotificationCenter.default.post( name: Notification.Name("SwitchToSettingsTab"), object: tab @@ -301,12 +254,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { defer: false ) + window.identifier = WindowIdentifiers.settings window.title = "Settings" window.center() window.setFrameAutosaveName("SettingsWindow") window.isReleasedWhenClosed = false window.contentView = NSHostingView( - rootView: SettingsWindowView(settingsManager: settingsManager!, initialTab: tab) + rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: tab) ) let windowController = NSWindowController(window: window) @@ -316,7 +270,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { NSApp.activate(ignoringOtherApps: true) - // Observe when window is closed to clean up reference NotificationCenter.default.addObserver( self, selector: #selector(settingsWindowWillCloseNotification(_:)), @@ -328,6 +281,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @objc private func settingsWindowWillCloseNotification(_ notification: Notification) { settingsWindowController = nil } + + /// Finds a window by its identifier + private func findWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> NSWindow? { + return NSApplication.shared.windows.first { $0.identifier == identifier } + } + + /// Brings window to front if it exists, returns true if found + private func activateWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> Bool { + guard let window = findWindow(withIdentifier: identifier) else { + return false + } + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return true + } } // Custom window class that can become key to receive keyboard events diff --git a/Gaze/Constants/WindowIdentifiers.swift b/Gaze/Constants/WindowIdentifiers.swift new file mode 100644 index 0000000..01a52f9 --- /dev/null +++ b/Gaze/Constants/WindowIdentifiers.swift @@ -0,0 +1,15 @@ +// +// WindowIdentifiers.swift +// Gaze +// +// Created by Mike Freno on 1/11/26. +// + +import AppKit + +/// Centralized window identifiers for robust window management +enum WindowIdentifiers { + static let onboarding = NSUserInterfaceItemIdentifier("com.gaze.window.onboarding") + static let settings = NSUserInterfaceItemIdentifier("com.gaze.window.settings") + static let reminder = NSUserInterfaceItemIdentifier("com.gaze.window.reminder") +} diff --git a/Gaze/Extensions/TimeIntervalExtensions.swift b/Gaze/Extensions/TimeIntervalExtensions.swift new file mode 100644 index 0000000..1fc1a15 --- /dev/null +++ b/Gaze/Extensions/TimeIntervalExtensions.swift @@ -0,0 +1,56 @@ +// +// TimeIntervalExtensions.swift +// Gaze +// +// Created by Mike Freno on 1/11/26. +// + +import Foundation + +extension TimeInterval { + /// Formats time interval as timer duration string + /// Examples: "5m 30s", "1h 23m", "45s" + func formatAsTimerDuration() -> String { + let seconds = Int(self) + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + + if minutes >= 60 { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + return String(format: "%dh %dm", hours, remainingMinutes) + } else if minutes > 0 { + return String(format: "%dm %ds", minutes, remainingSeconds) + } else { + return String(format: "%ds", remainingSeconds) + } + } + + /// Formats time interval with full precision (hours:minutes:seconds) + /// Example: "1:23:45" or "5:30" + func formatAsTimerDurationFull() -> String { + let seconds = Int(self) + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + + if minutes >= 60 { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + return String(format: "%d:%02d:%02d", hours, remainingMinutes, remainingSeconds) + } else { + return String(format: "%d:%02d", minutes, remainingSeconds) + } + } +} + +extension Int { + /// Formats integer seconds as timer duration + var asTimerDuration: String { + TimeInterval(self).formatAsTimerDuration() + } + + /// Formats integer seconds with full precision + var asTimerDurationFull: String { + TimeInterval(self).formatAsTimerDurationFull() + } +} diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift index 64f0eda..8533bfe 100644 --- a/Gaze/Models/AppSettings.swift +++ b/Gaze/Models/AppSettings.swift @@ -95,14 +95,18 @@ struct AppSettings: Codable, Equatable, Hashable { ) } + /// Manual Equatable implementation required because isAppStoreVersion + /// is excluded from Codable persistence but included in equality checks static func == (lhs: AppSettings, rhs: AppSettings) -> Bool { lhs.lookAwayTimer == rhs.lookAwayTimer && lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds - && lhs.blinkTimer == rhs.blinkTimer && lhs.postureTimer == rhs.postureTimer + && lhs.blinkTimer == rhs.blinkTimer + && lhs.postureTimer == rhs.postureTimer && lhs.userTimers == rhs.userTimers && lhs.subtleReminderSize == rhs.subtleReminderSize && lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding - && lhs.launchAtLogin == rhs.launchAtLogin && lhs.playSounds == rhs.playSounds + && lhs.launchAtLogin == rhs.launchAtLogin + && lhs.playSounds == rhs.playSounds && lhs.isAppStoreVersion == rhs.isAppStoreVersion } diff --git a/Gaze/Models/ReminderEvent.swift b/Gaze/Models/ReminderEvent.swift index 88c4de9..f60e22b 100644 --- a/Gaze/Models/ReminderEvent.swift +++ b/Gaze/Models/ReminderEvent.swift @@ -13,6 +13,19 @@ enum ReminderEvent: Equatable { case postureTriggered case userTimerTriggered(UserTimer) + var identifier: TimerIdentifier { + switch self { + case .lookAwayTriggered: + return .builtIn(.lookAway) + case .blinkTriggered: + return .builtIn(.blink) + case .postureTriggered: + return .builtIn(.posture) + case .userTimerTriggered(let timer): + return .user(id: timer.id) + } + } + var iconName: String { switch self { case .lookAwayTriggered: diff --git a/Gaze/Models/TimerConfiguration.swift b/Gaze/Models/TimerConfiguration.swift index 4fca85e..a3ad5d2 100644 --- a/Gaze/Models/TimerConfiguration.swift +++ b/Gaze/Models/TimerConfiguration.swift @@ -20,8 +20,4 @@ struct TimerConfiguration: Codable, Equatable, Hashable { get { intervalSeconds / 60 } set { intervalSeconds = newValue * 60 } } - - static func == (lhs: TimerConfiguration, rhs: TimerConfiguration) -> Bool { - lhs.enabled == rhs.enabled && lhs.intervalSeconds == rhs.intervalSeconds - } } diff --git a/Gaze/Models/TimerIdentifier.swift b/Gaze/Models/TimerIdentifier.swift new file mode 100644 index 0000000..f74de62 --- /dev/null +++ b/Gaze/Models/TimerIdentifier.swift @@ -0,0 +1,33 @@ +// +// TimerIdentifier.swift +// Gaze +// +// Created by Mike Freno on 1/12/26. +// + +import Foundation + +/// Unified identifier for both built-in and user-defined timers +enum TimerIdentifier: Hashable, Codable { + case builtIn(TimerType) + case user(id: String) + + var displayName: String { + switch self { + case .builtIn(let type): + return type.displayName + case .user: + // Will be looked up from settings in views + return "User Timer" + } + } + + var iconName: String { + switch self { + case .builtIn(let type): + return type.iconName + case .user: + return "clock.fill" + } + } +} diff --git a/Gaze/Models/TimerState.swift b/Gaze/Models/TimerState.swift index a16f977..3d66002 100644 --- a/Gaze/Models/TimerState.swift +++ b/Gaze/Models/TimerState.swift @@ -8,27 +8,19 @@ import Foundation struct TimerState: Equatable, Hashable { - let type: TimerType + let identifier: TimerIdentifier 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 + init(identifier: TimerIdentifier, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) { + self.identifier = identifier self.remainingSeconds = intervalSeconds self.isPaused = isPaused self.isActive = isActive self.targetDate = Date().addingTimeInterval(Double(intervalSeconds)) self.originalIntervalSeconds = intervalSeconds } - - static func == (lhs: TimerState, rhs: TimerState) -> Bool { - lhs.type == rhs.type && lhs.remainingSeconds == rhs.remainingSeconds - && 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 084c8c6..534fa07 100644 --- a/Gaze/Models/UserTimer.swift +++ b/Gaze/Models/UserTimer.swift @@ -39,12 +39,6 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable { self.colorHex = colorHex ?? UserTimer.defaultColors[0] self.enabled = enabled } - - static func == (lhs: UserTimer, rhs: UserTimer) -> Bool { - lhs.id == rhs.id && lhs.title == rhs.title && lhs.type == rhs.type - && lhs.timeOnScreenSeconds == rhs.timeOnScreenSeconds && lhs.intervalMinutes == rhs.intervalMinutes - && lhs.message == rhs.message && lhs.colorHex == rhs.colorHex && lhs.enabled == rhs.enabled - } // Default color palette for user timers static let defaultColors = [ diff --git a/Gaze/Services/SettingsManager.swift b/Gaze/Services/SettingsManager.swift index f8d2325..c98e471 100644 --- a/Gaze/Services/SettingsManager.swift +++ b/Gaze/Services/SettingsManager.swift @@ -12,14 +12,18 @@ import Foundation class SettingsManager: ObservableObject { static let shared = SettingsManager() - @Published var settings: AppSettings { - didSet { - save() - } - } + @Published var settings: AppSettings private let userDefaults = UserDefaults.standard private let settingsKey = "gazeAppSettings" + private var saveCancellable: AnyCancellable? + + private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = + [ + .lookAway: \.lookAwayTimer, + .blink: \.blinkTimer, + .posture: \.postureTimer, + ] private init() { #if DEBUG @@ -27,6 +31,24 @@ class SettingsManager: ObservableObject { UserDefaults.standard.removeObject(forKey: "gazeAppSettings") #endif self.settings = Self.loadSettings() + #if DEBUG + validateTimerConfigMappings() + #endif + setupDebouncedSave() + } + + deinit { + saveCancellable?.cancel() + // Final save will be called by AppDelegate.applicationWillTerminate + } + + private func setupDebouncedSave() { + saveCancellable = + $settings + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.save() + } } private static func loadSettings() -> AppSettings { @@ -38,6 +60,9 @@ class SettingsManager: ObservableObject { return settings } + /// Saves settings to UserDefaults. + /// Note: Settings are automatically saved via debouncing (500ms delay) when the `settings` property changes. + /// This method is also called explicitly during app termination to ensure final state is persisted. func save() { guard let data = try? JSONEncoder().encode(settings) else { print("Failed to encode settings") @@ -55,27 +80,39 @@ class SettingsManager: ObservableObject { } func timerConfiguration(for type: TimerType) -> TimerConfiguration { - switch type { - case .lookAway: - return settings.lookAwayTimer - case .blink: - return settings.blinkTimer - case .posture: - return settings.postureTimer + guard let keyPath = timerConfigKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") } + return settings[keyPath: keyPath] } func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { - switch type { - case .lookAway: - settings.lookAwayTimer = configuration - case .blink: - settings.blinkTimer = configuration - case .posture: - settings.postureTimer = configuration + guard let keyPath = timerConfigKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + settings[keyPath: keyPath] = configuration + } + + /// Returns all timer configurations as a dictionary + func allTimerConfigurations() -> [TimerType: TimerConfiguration] { + var configs: [TimerType: TimerConfiguration] = [:] + for (type, keyPath) in timerConfigKeyPaths { + configs[type] = settings[keyPath: keyPath] + } + return configs + } + + /// Validates that all timer types have configuration mappings + private func validateTimerConfigMappings() { + let allTypes = Set(TimerType.allCases) + let mappedTypes = Set(timerConfigKeyPaths.keys) + + let missing = allTypes.subtracting(mappedTypes) + if !missing.isEmpty { + preconditionFailure("Missing timer configuration mappings for: \(missing)") } } - + /// Detects and caches the App Store version status. /// This should be called once at app launch to avoid async checks throughout the app. func detectAppStoreVersion() async { diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index ac3f5a0..9deb924 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -10,19 +10,12 @@ import Foundation @MainActor class TimerEngine: ObservableObject { - @Published var timerStates: [TimerType: TimerState] = [:] + @Published var timerStates: [TimerIdentifier: TimerState] = [:] @Published var activeReminder: ReminderEvent? - - // Track user timer states separately - private var userTimerStates: [String: TimerState] = [:] - - // Expose user timer states for read-only access - var userTimerStatesReadOnly: [String: TimerState] { - return userTimerStates - } private var timerSubscription: AnyCancellable? private let settingsManager: SettingsManager + private var sleepStartTime: Date? init(settingsManager: SettingsManager) { self.settingsManager = settingsManager @@ -38,13 +31,15 @@ class TimerEngine: ObservableObject { // Initial start - create all timer states stop() - var newStates: [TimerType: TimerState] = [:] + var newStates: [TimerIdentifier: TimerState] = [:] + // Add built-in timers for timerType in TimerType.allCases { let config = settingsManager.timerConfiguration(for: timerType) if config.enabled { - newStates[timerType] = TimerState( - type: timerType, + let identifier = TimerIdentifier.builtIn(timerType) + newStates[identifier] = TimerState( + identifier: identifier, intervalSeconds: config.intervalSeconds, isPaused: false, isActive: true @@ -52,14 +47,20 @@ class TimerEngine: ObservableObject { } } + // Add user timers + for userTimer in settingsManager.settings.userTimers where userTimer.enabled { + let identifier = TimerIdentifier.user(id: userTimer.id) + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: userTimer.intervalMinutes * 60, + isPaused: false, + isActive: true + ) + } + // Assign the entire dictionary at once to trigger @Published timerStates = newStates - // Start user timers - for userTimer in settingsManager.settings.userTimers { - startUserTimer(userTimer) - } - timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in @@ -70,30 +71,32 @@ class TimerEngine: ObservableObject { } private func updateConfigurations() { - var newStates: [TimerType: TimerState] = [:] + var newStates: [TimerIdentifier: TimerState] = [:] + // Update built-in timers for timerType in TimerType.allCases { let config = settingsManager.timerConfiguration(for: timerType) + let identifier = TimerIdentifier.builtIn(timerType) if config.enabled { - if let existingState = timerStates[timerType] { + if let existingState = timerStates[identifier] { // Timer exists - check if interval changed if existingState.originalIntervalSeconds != config.intervalSeconds { // Interval changed - reset with new interval - newStates[timerType] = TimerState( - type: timerType, + newStates[identifier] = TimerState( + identifier: identifier, intervalSeconds: config.intervalSeconds, isPaused: existingState.isPaused, isActive: true ) } else { // Interval unchanged - keep existing state - newStates[timerType] = existingState + newStates[identifier] = existingState } } else { // Timer was just enabled - create new state - newStates[timerType] = TimerState( - type: timerType, + newStates[identifier] = TimerState( + identifier: identifier, intervalSeconds: config.intervalSeconds, isPaused: false, isActive: true @@ -103,82 +106,85 @@ class TimerEngine: ObservableObject { // If config.enabled is false and timer exists, it will be removed } + // Update user timers + for userTimer in settingsManager.settings.userTimers { + let identifier = TimerIdentifier.user(id: userTimer.id) + let newIntervalSeconds = userTimer.intervalMinutes * 60 + + if userTimer.enabled { + if let existingState = timerStates[identifier] { + // Check if interval changed + if existingState.originalIntervalSeconds != newIntervalSeconds { + // Interval changed - reset with new interval + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: newIntervalSeconds, + isPaused: existingState.isPaused, + isActive: true + ) + } else { + // Interval unchanged - keep existing state + newStates[identifier] = existingState + } + } else { + // New timer - create state + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: newIntervalSeconds, + isPaused: false, + isActive: true + ) + } + } + // If timer is disabled, it will be removed + } + // 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 - let newIntervalSeconds = userTimer.intervalMinutes * 60 - if existingState.originalIntervalSeconds != newIntervalSeconds { - // Interval changed - reset with new interval - userTimerStates[userTimer.id] = TimerState( - type: .lookAway, // Placeholder - intervalSeconds: newIntervalSeconds, - 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() timerSubscription = nil timerStates.removeAll() - userTimerStates.removeAll() } func pause() { - for (type, _) in timerStates { - timerStates[type]?.isPaused = true + for (id, _) in timerStates { + timerStates[id]?.isPaused = true } } func resume() { - for (type, _) in timerStates { - timerStates[type]?.isPaused = false + for (id, _) in timerStates { + timerStates[id]?.isPaused = false } } - func pauseTimer(type: TimerType) { - timerStates[type]?.isPaused = true + func pauseTimer(identifier: TimerIdentifier) { + timerStates[identifier]?.isPaused = true } - func resumeTimer(type: TimerType) { - timerStates[type]?.isPaused = false + func resumeTimer(identifier: TimerIdentifier) { + timerStates[identifier]?.isPaused = false } - func skipNext(type: TimerType) { - guard let state = timerStates[type] else { return } - let config = settingsManager.timerConfiguration(for: type) - timerStates[type] = TimerState( - type: type, - intervalSeconds: config.intervalSeconds, + func skipNext(identifier: TimerIdentifier) { + guard let state = timerStates[identifier] else { return } + + let intervalSeconds: Int + switch identifier { + case .builtIn(let type): + let config = settingsManager.timerConfiguration(for: type) + intervalSeconds = config.intervalSeconds + case .user(let id): + guard let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) else { return } + intervalSeconds = userTimer.intervalMinutes * 60 + } + + timerStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: intervalSeconds, isPaused: state.isPaused, isActive: state.isActive ) @@ -188,173 +194,114 @@ class TimerEngine: ObservableObject { guard let reminder = activeReminder else { return } activeReminder = nil - // Skip to next interval based on reminder type - switch reminder { - case .lookAwayTriggered, .blinkTriggered, .postureTriggered: - // For built-in timers, we need to extract the TimerType - if case .lookAwayTriggered = reminder { - skipNext(type: .lookAway) - resume() - } else if case .blinkTriggered = reminder { - skipNext(type: .blink) - } else if case .postureTriggered = reminder { - skipNext(type: .posture) - } - case .userTimerTriggered(let timer): - // Reset the user timer - if let state = userTimerStates[timer.id] { - userTimerStates[timer.id] = TimerState( - type: .lookAway, // Placeholder - intervalSeconds: timer.intervalMinutes * 60, - isPaused: state.isPaused, - isActive: state.isActive - ) - } - } + // Skip to next interval and resume the timer that was paused + let identifier = reminder.identifier + skipNext(identifier: identifier) + resumeTimer(identifier: identifier) } private func handleTick() { - guard activeReminder == nil else { return } - - // Handle regular timers first - for (type, state) in timerStates { + // Handle all timers uniformly - only skip the timer that has an active reminder + for (identifier, state) in timerStates { guard state.isActive && !state.isPaused else { continue } + + // Skip the timer that triggered the current reminder + if let activeReminder = activeReminder, activeReminder.identifier == identifier { + continue + } + // prevent overshoot - in case user closes laptop while timer is running, we don't want to - // trigger on open, + // trigger on open if state.targetDate < Date() - 3.0 { // slight grace // Reset the timer when it has overshot its interval - let config = settingsManager.timerConfiguration(for: type) - timerStates[type] = TimerState( - type: type, - intervalSeconds: config.intervalSeconds, - isPaused: state.isPaused, - isActive: state.isActive - ) + skipNext(identifier: identifier) continue // Skip normal countdown logic after reset } - timerStates[type]?.remainingSeconds -= 1 + timerStates[identifier]?.remainingSeconds -= 1 - if let updatedState = timerStates[type], updatedState.remainingSeconds <= 0 { - triggerReminder(for: type) + if let updatedState = timerStates[identifier], updatedState.remainingSeconds <= 0 { + triggerReminder(for: identifier) break } } + } + + func triggerReminder(for identifier: TimerIdentifier) { + // Pause only the timer that triggered + pauseTimer(identifier: identifier) - // Handle user timers - handleUserTimerTicks() - } - - private func handleUserTimerTicks() { - for (id, state) in userTimerStates { - if !state.isActive || state.isPaused { continue } - - // Update user timer countdown - userTimerStates[id]?.remainingSeconds -= 1 - - if let updatedState = userTimerStates[id], updatedState.remainingSeconds <= 0 { - // Trigger the user timer reminder - triggerUserTimerReminder(forId: id) + switch identifier { + case .builtIn(let type): + switch type { + case .lookAway: + activeReminder = .lookAwayTriggered( + countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds) + case .blink: + activeReminder = .blinkTriggered + case .posture: + activeReminder = .postureTriggered } - } - } - - private func triggerUserTimerReminder(forId id: String) { - if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) { - activeReminder = .userTimerTriggered(userTimer) - } - } - - func triggerReminder(for type: TimerType) { - switch type { - case .lookAway: - pause() - activeReminder = .lookAwayTriggered( - countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds) - case .blink: - activeReminder = .blinkTriggered - case .posture: - activeReminder = .postureTriggered - } - } - - // User timer management methods - func startUserTimer(_ userTimer: UserTimer) { - userTimerStates[userTimer.id] = TimerState( - type: .lookAway, // Placeholder - we'll need to make this more flexible - intervalSeconds: userTimer.intervalMinutes * 60, - isPaused: false, - isActive: true - ) - } - - func stopUserTimer(_ userTimerId: String) { - userTimerStates[userTimerId] = nil - } - - func pauseUserTimer(_ userTimerId: String) { - if var state = userTimerStates[userTimerId] { - state.isPaused = true - userTimerStates[userTimerId] = state - } - } - - func resumeUserTimer(_ userTimerId: String) { - if var state = userTimerStates[userTimerId] { - state.isPaused = false - userTimerStates[userTimerId] = state - } - } - - func toggleUserTimerPause(_ userTimerId: String) { - if let state = userTimerStates[userTimerId] { - if state.isPaused { - resumeUserTimer(userTimerId) - } else { - pauseUserTimer(userTimerId) + case .user(let id): + if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) { + activeReminder = .userTimerTriggered(userTimer) } } } - func getTimeRemaining(for type: TimerType) -> TimeInterval { - guard let state = timerStates[type] else { return 0 } - return TimeInterval(state.remainingSeconds) - } - - func getUserTimeRemaining(for userId: String) -> TimeInterval { - guard let state = userTimerStates[userId] else { return 0 } + func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval { + guard let state = timerStates[identifier] else { return 0 } return TimeInterval(state.remainingSeconds) } - func getFormattedTimeRemaining(for type: TimerType) -> String { - let seconds = Int(getTimeRemaining(for: type)) - let minutes = seconds / 60 - let remainingSeconds = seconds % 60 - - if minutes >= 60 { - let hours = minutes / 60 - let remainingMinutes = minutes % 60 - return String(format: "%d:%02d:%02d", hours, remainingMinutes, remainingSeconds) - } else { - return String(format: "%d:%02d", minutes, remainingSeconds) - } + func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String { + return getTimeRemaining(for: identifier).formatAsTimerDurationFull() } - func isUserTimerPaused(_ userTimerId: String) -> Bool { - return userTimerStates[userTimerId]?.isPaused ?? true + func isTimerPaused(_ identifier: TimerIdentifier) -> Bool { + return timerStates[identifier]?.isPaused ?? true } - func getUserFormattedTimeRemaining(for userId: String) -> String { - let seconds = Int(getUserTimeRemaining(for: userId)) - let minutes = seconds / 60 - let remainingSeconds = seconds % 60 - - if minutes >= 60 { - let hours = minutes / 60 - let remainingMinutes = minutes % 60 - return String(format: "%d:%02d:%02d", hours, remainingMinutes, remainingSeconds) - } else { - return String(format: "%d:%02d", minutes, remainingSeconds) + /// Handles system sleep event + /// - Saves current time for elapsed calculation + /// - Pauses all active timers + func handleSystemSleep() { + sleepStartTime = Date() + pause() + } + + /// Handles system wake event + /// - Calculates elapsed time during sleep + /// - Adjusts remaining time for all active timers + /// - Timers that expired during sleep will trigger immediately (1s delay) + /// - Resumes all timers + func handleSystemWake() { + guard let sleepStart = sleepStartTime else { + return } + + defer { + sleepStartTime = nil + } + + let elapsedSeconds = Int(Date().timeIntervalSince(sleepStart)) + + guard elapsedSeconds >= 1 else { + resume() + return + } + + for (identifier, state) in timerStates where state.isActive && !state.isPaused { + var updatedState = state + updatedState.remainingSeconds = max(0, state.remainingSeconds - elapsedSeconds) + + if updatedState.remainingSeconds <= 0 { + updatedState.remainingSeconds = 1 + } + + timerStates[identifier] = updatedState + } + + resume() } } diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index ff76c2b..dcf1f3e 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -187,53 +187,37 @@ struct MenuBarContentView: View { .padding(.horizontal) .padding(.top, 8) - // Show regular timers with individual pause/resume controls - ForEach(Array(timerEngine.timerStates.keys), id: \.self) { timerType in - if let state = timerEngine.timerStates[timerType] { + // Show all timers using unified identifier system + ForEach(getSortedTimerIdentifiers(timerEngine: timerEngine), id: \.self) { identifier in + if let state = timerEngine.timerStates[identifier] { TimerStatusRowWithIndividualControls( - variant: .builtIn(timerType), + identifier: identifier, timerEngine: timerEngine, + settingsManager: settingsManager, onSkip: { - timerEngine.skipNext(type: timerType) + timerEngine.skipNext(identifier: identifier) }, onDevTrigger: { - timerEngine.triggerReminder(for: timerType) + timerEngine.triggerReminder(for: identifier) }, onTogglePause: { isPaused in if isPaused { - timerEngine.pauseTimer(type: timerType) + timerEngine.pauseTimer(identifier: identifier) } else { - timerEngine.resumeTimer(type: timerType) + timerEngine.resumeTimer(identifier: identifier) } }, onTap: { - onOpenSettingsTab(timerType.tabIndex) + switch identifier { + case .builtIn(let type): + onOpenSettingsTab(type.tabIndex) + case .user: + onOpenSettingsTab(3) // User Timers tab + } } ) } } - - // Show user timers with individual pause/resume controls - ForEach(settingsManager.settings.userTimers.filter { $0.enabled }, id: \.id) { - userTimer in - TimerStatusRowWithIndividualControls( - variant: .user(userTimer), - timerEngine: timerEngine, - onSkip: { - //TODO - }, - onTogglePause: { isPaused in - if isPaused { - timerEngine.pauseUserTimer(userTimer.id) - } else { - timerEngine.resumeUserTimer(userTimer.id) - } - }, - onTap: { - onOpenSettingsTab(3) // Switch to User Timers tab - } - ) - } } .padding(.bottom, 8) @@ -308,50 +292,28 @@ struct MenuBarContentView: View { let activeStates = timerEngine.timerStates.values.filter { $0.isActive } return !activeStates.isEmpty && activeStates.allSatisfy { $0.isPaused } } -} - -struct TimerStatusRowWithIndividualControls: View { - enum TimerVariant { - case builtIn(TimerType) - case user(UserTimer) - - var displayName: String { - switch self { - case .builtIn(let type): return type.displayName - case .user(let timer): return timer.title - } - } - - var iconName: String { - switch self { - case .builtIn(let type): return type.iconName - case .user: return "clock.fill" - } - } - - var color: Color { - switch self { - case .builtIn(_): - return .accentColor - - case .user(let timer): return timer.color - } - } - - var tooltipText: String { - switch self { - case .builtIn(let type): return type.tooltipText - case .user(let timer): - let typeText = timer.type == .subtle ? "Subtle" : "Overlay" - let durationText = "\(timer.timeOnScreenSeconds)s on screen" - let statusText = timer.enabled ? "" : " (Disabled)" - return "\(typeText) timer - \(durationText)\(statusText)" + + private func getSortedTimerIdentifiers(timerEngine: TimerEngine) -> [TimerIdentifier] { + return timerEngine.timerStates.keys.sorted { id1, id2 in + // Sort built-in timers before user timers + switch (id1, id2) { + case (.builtIn(let t1), .builtIn(let t2)): + return t1.tabIndex < t2.tabIndex + case (.builtIn, .user): + return true + case (.user, .builtIn): + return false + case (.user(let id1), .user(let id2)): + return id1 < id2 } } } +} - let variant: TimerVariant +struct TimerStatusRowWithIndividualControls: View { + let identifier: TimerIdentifier @ObservedObject var timerEngine: TimerEngine + @ObservedObject var settingsManager: SettingsManager var onSkip: () -> Void var onDevTrigger: (() -> Void)? = nil var onTogglePause: (Bool) -> Void @@ -362,46 +324,89 @@ struct TimerStatusRowWithIndividualControls: View { @State private var isHoveredPauseButton = false private var state: TimerState? { - switch variant { - case .builtIn(let type): - return timerEngine.timerStates[type] - case .user(let timer): - return timerEngine.userTimerStatesReadOnly[timer.id] - } + return timerEngine.timerStates[identifier] } private var isPaused: Bool { - switch variant { - case .builtIn: - return state?.isPaused ?? false - case .user(let timer): - return !timer.enabled + return state?.isPaused ?? false + } + + private var displayName: String { + switch identifier { + case .builtIn(let type): + return type.displayName + case .user(let id): + return settingsManager.settings.userTimers.first(where: { $0.id == id })?.title ?? "User Timer" } } + + private var iconName: String { + switch identifier { + case .builtIn(let type): + return type.iconName + case .user: + return "clock.fill" + } + } + + private var color: Color { + switch identifier { + case .builtIn(let type): + switch type { + case .lookAway: return .accentColor + case .blink: return .green + case .posture: return .orange + } + case .user(let id): + return settingsManager.settings.userTimers.first(where: { $0.id == id })?.color ?? .purple + } + } + + private var tooltipText: String { + switch identifier { + case .builtIn(let type): + return type.tooltipText + case .user(let id): + guard let timer = settingsManager.settings.userTimers.first(where: { $0.id == id }) else { + return "User Timer" + } + let typeText = timer.type == .subtle ? "Subtle" : "Overlay" + let durationText = "\(timer.timeOnScreenSeconds)s on screen" + let statusText = timer.enabled ? "" : " (Disabled)" + return "\(typeText) timer - \(durationText)\(statusText)" + } + } + + private var userTimer: UserTimer? { + if case .user(let id) = identifier { + return settingsManager.settings.userTimers.first(where: { $0.id == id }) + } + return nil + } var body: some View { HStack { HStack { // Show color indicator circle for user timers - if case .user(let timer) = variant { + if let timer = userTimer { Circle() .fill(isHoveredBody ? .white : timer.color) .frame(width: 8, height: 8) } - Image(systemName: variant.iconName) - .foregroundColor(isHoveredBody ? .white : variant.color) + Image(systemName: iconName) + .foregroundColor(isHoveredBody ? .white : color) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { - Text(variant.displayName) + Text(displayName) .font(.subheadline) .fontWeight(.medium) .foregroundColor(isHoveredBody ? .white : .primary) .lineLimit(1) if let state = state { - Text(timeRemaining(state)) + Text(state.remainingSeconds.asTimerDuration) .font(.caption) .foregroundColor(isHoveredBody ? .white.opacity(0.8) : .secondary) .monospacedDigit() @@ -430,7 +435,7 @@ struct TimerStatusRowWithIndividualControls: View { ? GlassStyle.regular.tint(.yellow) : GlassStyle.regular, in: .circle ) - .help("Trigger \(variant.displayName) reminder now (dev)") + .help("Trigger \(displayName) reminder now (dev)") .onHover { hovering in isHoveredDevTrigger = hovering } @@ -457,7 +462,7 @@ struct TimerStatusRowWithIndividualControls: View { ) .help( isPaused - ? "Resume \(variant.displayName)" : "Pause \(variant.displayName)" + ? "Resume \(displayName)" : "Pause \(displayName)" ) .onHover { hovering in isHoveredPauseButton = hovering @@ -476,7 +481,7 @@ struct TimerStatusRowWithIndividualControls: View { ? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular, in: .circle ) - .help("Skip to next \(variant.displayName) reminder") + .help("Skip to next \(displayName) reminder") .onHover { hovering in isHoveredSkip = hovering } @@ -485,7 +490,7 @@ struct TimerStatusRowWithIndividualControls: View { .padding(.vertical, 6) .glassEffectIfAvailable( isHoveredBody - ? GlassStyle.regular.tint(variant.color) + ? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular, in: .rect(cornerRadius: 6) ) @@ -493,24 +498,9 @@ struct TimerStatusRowWithIndividualControls: View { .onHover { hovering in isHoveredBody = hovering } - .help(variant.tooltipText) + .help(tooltipText) } - private func timeRemaining(_ state: TimerState) -> String { - let seconds = state.remainingSeconds - let minutes = seconds / 60 - let remainingSeconds = seconds % 60 - - if minutes >= 60 { - let hours = minutes / 60 - let remainingMinutes = minutes % 60 - return String(format: "%dh %dm", hours, remainingMinutes) - } else if minutes > 0 { - return String(format: "%dm %ds", minutes, remainingSeconds) - } else { - return String(format: "%ds", remainingSeconds) - } - } } #Preview("Menu Bar Content") {