checkpoint

This commit is contained in:
Michael Freno
2026-01-27 13:36:59 -05:00
parent a5602d9829
commit fda136f3d4
6 changed files with 467 additions and 51 deletions

View File

@@ -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.

View File

@@ -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()
}
}

View File

@@ -64,7 +64,8 @@ final class ServiceContainer {
}
let engine = TimerEngine(
settingsManager: settingsManager,
enforceModeService: enforceModeService
enforceModeService: enforceModeService,
timeProvider: isTestEnvironment ? MockTimeProvider() : SystemTimeProvider()
)
_timerEngine = engine
return engine

View File

@@ -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<AnyCancellable>()
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")
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}