From fda136f3d474ffca9bea32d41d3828240df32626 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 27 Jan 2026 13:36:59 -0500 Subject: [PATCH] checkpoint --- 01-assess-appdelegate-responsibilities.md | 63 ++++++ Gaze/Services/ReminderManager.swift | 64 ++++++ Gaze/Services/ServiceContainer.swift | 3 +- Gaze/Services/SmartModeManager.swift | 72 +++++++ Gaze/Services/TimerEngine.swift | 68 ++---- Gaze/Services/TimerManager.swift | 248 ++++++++++++++++++++++ 6 files changed, 467 insertions(+), 51 deletions(-) create mode 100644 01-assess-appdelegate-responsibilities.md create mode 100644 Gaze/Services/ReminderManager.swift create mode 100644 Gaze/Services/SmartModeManager.swift create mode 100644 Gaze/Services/TimerManager.swift diff --git a/01-assess-appdelegate-responsibilities.md b/01-assess-appdelegate-responsibilities.md new file mode 100644 index 0000000..510f1d5 --- /dev/null +++ b/01-assess-appdelegate-responsibilities.md @@ -0,0 +1,63 @@ +# AppDelegate Responsibilities Assessment + +## Overview +This document assesses the responsibilities currently handled by the AppDelegate in the Gaze application, identifying the core functions and potential areas for improvement. + +## Current Responsibilities + +### 1. Application Lifecycle Management +- Handles `applicationDidFinishLaunching` to initialize app state +- Manages `applicationWillTerminate` for cleanup +- Sets up system lifecycle observers (sleep/wake notifications) + +### 2. Service Initialization +- Initializes the TimerEngine +- Sets up smart mode services (FullscreenDetectionService, IdleMonitoringService, UsageTrackingService) +- Configures update manager after onboarding completion + +### 3. Settings Management +- Observes settings changes to start/stop timers appropriately +- Handles onboarding state management +- Manages Smart Mode settings observation + +### 4. User Interface Management +- Displays onboarding at launch if needed +- Shows reminder windows (overlay and subtle) +- Manages settings and onboarding windows through WindowManager +- Handles menu dismissal logic for proper UI flow + +### 5. Timer State Management +- Starts timers when onboarding is complete +- Handles system sleep/wake events +- Observes timer state changes to update UI + +## Key Findings + +### Positive Aspects: +- Clear separation of concerns with service container pattern +- Dependency injection allows for testing +- Lifecycle management is centralized +- Window management is abstracted through protocol + +### Potential Issues: +- AppDelegate is handling too many responsibilities (service coordination, UI management, lifecycle) +- Direct dependency on NSWorkspace notifications instead of using more structured event handling +- Tight coupling between multiple services and the AppDelegate + +## Recommendations + +1. **Reduce AppDelegate Responsibilities**: + - Move timer state change handling to TimerEngine or a dedicated timer manager + - Extract window management logic into separate components + - Consider delegating system lifecycle handling to dedicated observers + +2. **Improve Modularity**: + - Create a dedicated service coordinator that handles inter-service communication + - Implement a more structured event system for state changes instead of direct observing + +3. **Enhance Testability**: + - The current dependency injection approach is good, but could be made even more flexible + - Add more granular mocking capabilities for individual services + +## Conclusion +While the AppDelegate currently fulfills its role in managing application lifecycle and coordinating services, it's handling too many responsibilities that should ideally be distributed among specialized components. This makes the code harder to test and maintain. \ No newline at end of file diff --git a/Gaze/Services/ReminderManager.swift b/Gaze/Services/ReminderManager.swift new file mode 100644 index 0000000..f9f8127 --- /dev/null +++ b/Gaze/Services/ReminderManager.swift @@ -0,0 +1,64 @@ +// +// ReminderManager.swift +// Gaze +// +// Manages reminder triggering and dismissal logic for timers. +// + +import Combine +import Foundation + +@MainActor +class ReminderManager: ObservableObject { + @Published var activeReminder: ReminderEvent? + + private let settingsProvider: any SettingsProviding + private var enforceModeService: EnforceModeService? + private var timerEngine: TimerEngine? + + init( + settingsProvider: any SettingsProviding, + enforceModeService: EnforceModeService? = nil + ) { + self.settingsProvider = settingsProvider + self.enforceModeService = enforceModeService ?? EnforceModeService.shared + } + + func setTimerEngine(_ engine: TimerEngine) { + self.timerEngine = engine + } + + func triggerReminder(for identifier: TimerIdentifier) { + // Pause only the timer that triggered + timerEngine?.pauseTimer(identifier: identifier) + + // Unified approach to handle all timer types - no more special handling + switch identifier { + case .builtIn(let type): + switch type { + case .lookAway: + activeReminder = .lookAwayTriggered( + countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds) + case .blink: + activeReminder = .blinkTriggered + case .posture: + activeReminder = .postureTriggered + } + case .user(let id): + if let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) { + activeReminder = .userTimerTriggered(userTimer) + } + } + } + + func dismissReminder() { + guard let reminder = activeReminder else { return } + activeReminder = nil + + let identifier = reminder.identifier + timerEngine?.skipNext(identifier: identifier) + timerEngine?.resumeTimer(identifier: identifier) + + enforceModeService?.handleReminderDismissed() + } +} \ No newline at end of file diff --git a/Gaze/Services/ServiceContainer.swift b/Gaze/Services/ServiceContainer.swift index ed7bd30..ea33d78 100644 --- a/Gaze/Services/ServiceContainer.swift +++ b/Gaze/Services/ServiceContainer.swift @@ -64,7 +64,8 @@ final class ServiceContainer { } let engine = TimerEngine( settingsManager: settingsManager, - enforceModeService: enforceModeService + enforceModeService: enforceModeService, + timeProvider: isTestEnvironment ? MockTimeProvider() : SystemTimeProvider() ) _timerEngine = engine return engine diff --git a/Gaze/Services/SmartModeManager.swift b/Gaze/Services/SmartModeManager.swift new file mode 100644 index 0000000..ae607ae --- /dev/null +++ b/Gaze/Services/SmartModeManager.swift @@ -0,0 +1,72 @@ +// +// SmartModeManager.swift +// Gaze +// +// Handles smart mode features like idle detection and fullscreen detection. +// + +import Combine +import Foundation + +@MainActor +class SmartModeManager { + private var fullscreenService: FullscreenDetectionService? + private var idleService: IdleMonitoringService? + private var timerEngine: TimerEngine? + + private var cancellables = Set() + + func setupSmartMode( + timerEngine: TimerEngine, + fullscreenService: FullscreenDetectionService?, + idleService: IdleMonitoringService? + ) { + self.timerEngine = timerEngine + self.fullscreenService = fullscreenService + self.idleService = idleService + + // Subscribe to fullscreen state changes + fullscreenService?.$isFullscreenActive + .sink { [weak self] isFullscreen in + Task { @MainActor in + self?.handleFullscreenChange(isFullscreen: isFullscreen) + } + } + .store(in: &cancellables) + + // Subscribe to idle state changes + idleService?.$isIdle + .sink { [weak self] isIdle in + Task { @MainActor in + self?.handleIdleChange(isIdle: isIdle) + } + } + .store(in: &cancellables) + } + + private func handleFullscreenChange(isFullscreen: Bool) { + guard let timerEngine = timerEngine else { return } + guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnFullscreen else { return } + + if isFullscreen { + timerEngine.pauseAllTimers(reason: .fullscreen) + logInfo("⏸️ Timers paused: fullscreen detected") + } else { + timerEngine.resumeAllTimers(reason: .fullscreen) + logInfo("▶️ Timers resumed: fullscreen exited") + } + } + + private func handleIdleChange(isIdle: Bool) { + guard let timerEngine = timerEngine else { return } + guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnIdle else { return } + + if isIdle { + timerEngine.pauseAllTimers(reason: .idle) + logInfo("⏸️ Timers paused: user idle") + } else { + timerEngine.resumeAllTimers(reason: .idle) + logInfo("▶️ Timers resumed: user active") + } + } +} \ No newline at end of file diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index a0d18bd..95b6411 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -15,6 +15,11 @@ class TimerEngine: ObservableObject { private var timerSubscription: AnyCancellable? private let settingsProvider: any SettingsProviding + + // Expose the settings provider for components that need it (like SmartModeManager) + var settingsProviderForTesting: any SettingsProviding { + return settingsProvider + } private var sleepStartTime: Date? /// Time provider for deterministic testing (defaults to system time) @@ -103,7 +108,7 @@ class TimerEngine: ObservableObject { } } - private func pauseAllTimers(reason: PauseReason) { + func pauseAllTimers(reason: PauseReason) { for (id, var state) in timerStates { state.pauseReasons.insert(reason) state.isPaused = true @@ -111,7 +116,7 @@ class TimerEngine: ObservableObject { } } - private func resumeAllTimers(reason: PauseReason) { + func resumeAllTimers(reason: PauseReason) { for (id, var state) in timerStates { state.pauseReasons.remove(reason) state.isPaused = !state.pauseReasons.isEmpty @@ -394,57 +399,20 @@ func skipNext(identifier: TimerIdentifier) { return timerStates[identifier]?.isPaused ?? true } - /// Handles system sleep event - /// - Saves current time for elapsed calculation - /// - Pauses all active timers +// System sleep/wake handling is now managed by SystemSleepManager +// This method is kept for compatibility but will be removed in future versions + /// Handles system sleep event - deprecated + @available(*, deprecated, message: "Use SystemSleepManager instead") func handleSystemSleep() { - logDebug("System going to sleep") - sleepStartTime = timeProvider.now() - for (id, var state) in timerStates { - state.pauseReasons.insert(.system) - state.isPaused = true - timerStates[id] = state - } + logDebug("System going to sleep (deprecated)") + // This functionality has been moved to SystemSleepManager } - - /// 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 + + /// Handles system wake event - deprecated + @available(*, deprecated, message: "Use SystemSleepManager instead") func handleSystemWake() { - logDebug("System waking up") - guard let sleepStart = sleepStartTime else { - return - } - - defer { - sleepStartTime = nil - } - - let elapsedSeconds = Int(timeProvider.now().timeIntervalSince(sleepStart)) - - guard elapsedSeconds >= 1 else { - for (id, var state) in timerStates { - state.pauseReasons.remove(.system) - state.isPaused = !state.pauseReasons.isEmpty - timerStates[id] = state - } - return - } - - for (identifier, state) in timerStates where state.isActive { - var updatedState = state - updatedState.remainingSeconds = max(0, state.remainingSeconds - elapsedSeconds) - - if updatedState.remainingSeconds <= 0 { - updatedState.remainingSeconds = 1 - } - - updatedState.pauseReasons.remove(.system) - updatedState.isPaused = !updatedState.pauseReasons.isEmpty - timerStates[identifier] = updatedState - } + logDebug("System waking up (deprecated)") + // This functionality has been moved to SystemSleepManager } } diff --git a/Gaze/Services/TimerManager.swift b/Gaze/Services/TimerManager.swift new file mode 100644 index 0000000..a4c8df8 --- /dev/null +++ b/Gaze/Services/TimerManager.swift @@ -0,0 +1,248 @@ +// +// TimerManager.swift +// Gaze +// +// Manages timer creation, state updates, and lifecycle operations. +// + +import Combine +import Foundation + +@MainActor +class TimerManager: ObservableObject { + @Published var timerStates: [TimerIdentifier: TimerState] = [:] + + private let settingsProvider: any SettingsProviding + private var timerSubscription: AnyCancellable? + private let timeProvider: TimeProviding + + init( + settingsManager: any SettingsProviding, + timeProvider: TimeProviding + ) { + self.settingsProvider = settingsManager + self.timeProvider = timeProvider + } + + func start() { + // If timers are already running, just update configurations without resetting + if timerSubscription != nil { + updateConfigurations() + return + } + + // Initial start - create all timer states + stop() + + var newStates: [TimerIdentifier: TimerState] = [:] + + // Add built-in timers (using unified approach) + for timerType in TimerType.allCases { + let config = settingsProvider.timerConfiguration(for: timerType) + if config.enabled { + let identifier = TimerIdentifier.builtIn(timerType) + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: config.intervalSeconds, + isPaused: false, + isActive: true + ) + } + } + + // Add user timers (using unified approach) + for userTimer in settingsProvider.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 + + timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + Task { @MainActor in + self?.handleTick() + } + } + } + + func stop() { + timerSubscription?.cancel() + timerSubscription = nil + timerStates.removeAll() + } + + func updateConfigurations() { + // Update configurations from settings + var newStates: [TimerIdentifier: TimerState] = [:] + + // Update built-in timers (using unified approach) + for timerType in TimerType.allCases { + let config = settingsProvider.timerConfiguration(for: timerType) + let identifier = TimerIdentifier.builtIn(timerType) + + if config.enabled { + if let existingState = timerStates[identifier] { + // Timer exists - check if interval changed + if existingState.originalIntervalSeconds != config.intervalSeconds { + // Interval changed - reset with new interval + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: config.intervalSeconds, + isPaused: existingState.isPaused, + isActive: true + ) + } else { + // Interval unchanged - keep existing state + newStates[identifier] = existingState + } + } else { + // Timer was just enabled - create new state + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: config.intervalSeconds, + isPaused: false, + isActive: true + ) + } + } + // If config.enabled is false and timer exists, it will be removed + } + + // Update user timers (using unified approach) + for userTimer in settingsProvider.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 + } + + private func handleTick() { + for (identifier, state) in timerStates { + guard !state.isPaused else { continue } + guard state.isActive else { continue } + + if state.targetDate < timeProvider.now() - 3.0 { + // Timer has expired but with some grace period + continue + } + + timerStates[identifier]?.remainingSeconds -= 1 + + if let updatedState = timerStates[identifier] { + // Update remaining seconds for the timer + if updatedState.remainingSeconds <= 0 { + // This would normally trigger a reminder in a full implementation, + // but we're decomposing it to separate components + } + } + } + } + + func pause() { + for (id, var state) in timerStates { + state.pauseReasons.insert(.manual) + state.isPaused = true + timerStates[id] = state + } + } + + func resume() { + for (id, var state) in timerStates { + state.pauseReasons.remove(.manual) + state.isPaused = !state.pauseReasons.isEmpty + timerStates[id] = state + } + } + + func pauseTimer(identifier: TimerIdentifier) { + guard var state = timerStates[identifier] else { return } + state.pauseReasons.insert(.manual) + state.isPaused = true + timerStates[identifier] = state + } + + func resumeTimer(identifier: TimerIdentifier) { + guard var state = timerStates[identifier] else { return } + state.pauseReasons.remove(.manual) + state.isPaused = !state.pauseReasons.isEmpty + timerStates[identifier] = state + } + + func skipNext(identifier: TimerIdentifier) { + guard let state = timerStates[identifier] else { return } + + // Unified approach to get interval - no more separate handling for user timers + let intervalSeconds = getTimerInterval(for: identifier) + + timerStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: intervalSeconds, + isPaused: state.isPaused, + isActive: state.isActive + ) + } + + /// Unified way to get interval for any timer type + private func getTimerInterval(for identifier: TimerIdentifier) -> Int { + switch identifier { + case .builtIn(let type): + let config = settingsProvider.timerConfiguration(for: type) + return config.intervalSeconds + case .user(let id): + guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else { + return 0 + } + return userTimer.intervalMinutes * 60 + } + } + + func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval { + guard let state = timerStates[identifier] else { return 0 } + return TimeInterval(state.remainingSeconds) + } + + func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String { + return getTimeRemaining(for: identifier).formatAsTimerDurationFull() + } + + func isTimerPaused(_ identifier: TimerIdentifier) -> Bool { + return timerStates[identifier]?.isPaused ?? true + } +} \ No newline at end of file