diff --git a/Gaze/Services/EnforceMode/EnforceCameraController.swift b/Gaze/Services/EnforceMode/EnforceCameraController.swift deleted file mode 100644 index 0e8d8af..0000000 --- a/Gaze/Services/EnforceMode/EnforceCameraController.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// EnforceCameraController.swift -// Gaze -// -// Manages camera lifecycle for enforce mode sessions. -// - -import Combine -import Foundation - -protocol EnforceCameraControllerDelegate: AnyObject { - func cameraControllerDidTimeout(_ controller: EnforceCameraController) - func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool) -} - -@MainActor -final class EnforceCameraController: ObservableObject { - @Published private(set) var isCameraActive = false - @Published private(set) var lastFaceDetectionTime: Date = .distantPast - - weak var delegate: EnforceCameraControllerDelegate? - - private let eyeTrackingService: EyeTrackingService - private var cancellables = Set() - private var faceDetectionTimer: Timer? - var faceDetectionTimeout: TimeInterval = 5.0 - - init(eyeTrackingService: EyeTrackingService) { - self.eyeTrackingService = eyeTrackingService - setupObservers() - } - - func startCamera() async throws { - guard !isCameraActive else { return } - try await eyeTrackingService.startEyeTracking() - isCameraActive = true - lastFaceDetectionTime = Date() - startFaceDetectionTimer() - } - - func stopCamera() { - guard isCameraActive else { return } - eyeTrackingService.stopEyeTracking() - isCameraActive = false - stopFaceDetectionTimer() - } - - func resetFaceDetectionTimer() { - lastFaceDetectionTime = Date() - } - - private func setupObservers() { - eyeTrackingService.$userLookingAtScreen - .sink { [weak self] lookingAtScreen in - guard let self else { return } - self.delegate?.cameraController(self, didUpdateLookingAtScreen: lookingAtScreen) - } - .store(in: &cancellables) - - eyeTrackingService.$faceDetected - .sink { [weak self] faceDetected in - guard let self else { return } - if faceDetected { - self.lastFaceDetectionTime = Date() - } - } - .store(in: &cancellables) - } - - private func startFaceDetectionTimer() { - stopFaceDetectionTimer() - - faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - Task { @MainActor [weak self] in - self?.checkFaceDetectionTimeout() - } - } - - } - - private func stopFaceDetectionTimer() { - faceDetectionTimer?.invalidate() - faceDetectionTimer = nil - } - - private func checkFaceDetectionTimeout() { - guard isCameraActive else { - stopFaceDetectionTimer() - return - } - - let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime) - if timeSinceLastDetection > faceDetectionTimeout { - delegate?.cameraControllerDidTimeout(self) - } - } -} diff --git a/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift b/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift deleted file mode 100644 index a3d732e..0000000 --- a/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// EnforcePolicyEvaluator.swift -// Gaze -// -// Policy evaluation for enforce mode behavior. -// - -import Foundation - -enum ComplianceResult { - case compliant - case notCompliant - case faceNotDetected -} - -final class EnforcePolicyEvaluator { - private let settingsProvider: any SettingsProviding - - init(settingsProvider: any SettingsProviding) { - self.settingsProvider = settingsProvider - } - - var isEnforcementEnabled: Bool { - settingsProvider.isTimerEnabled(for: .lookAway) - } - - func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool { - guard isEnforcementEnabled else { return false } - - switch timerIdentifier { - case .builtIn(let type): - return type == .lookAway - case .user: - return false - } - } - - func shouldPreActivateCamera( - timerIdentifier: TimerIdentifier, - secondsRemaining: Int - ) -> Bool { - guard secondsRemaining <= 3 else { return false } - return shouldEnforce(timerIdentifier: timerIdentifier) - } - - func evaluateCompliance( - isLookingAtScreen: Bool, - faceDetected: Bool - ) -> ComplianceResult { - guard faceDetected else { return .faceNotDetected } - return isLookingAtScreen ? .notCompliant : .compliant - } -} diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift index 08b3282..611b20e 100644 --- a/Gaze/Services/EnforceModeService.swift +++ b/Gaze/Services/EnforceModeService.swift @@ -8,6 +8,139 @@ import Combine import Foundation +enum ComplianceResult { + case compliant + case notCompliant + case faceNotDetected +} + +protocol EnforceCameraControllerDelegate: AnyObject { + func cameraControllerDidTimeout(_ controller: EnforceCameraController) + func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool) +} + +final class EnforcePolicyEvaluator { + private let settingsProvider: any SettingsProviding + + init(settingsProvider: any SettingsProviding) { + self.settingsProvider = settingsProvider + } + + var isEnforcementEnabled: Bool { + settingsProvider.isTimerEnabled(for: .lookAway) + } + + func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool { + guard isEnforcementEnabled else { return false } + + switch timerIdentifier { + case .builtIn(let type): + return type == .lookAway + case .user: + return false + } + } + + func shouldPreActivateCamera( + timerIdentifier: TimerIdentifier, + secondsRemaining: Int + ) -> Bool { + guard secondsRemaining <= 3 else { return false } + return shouldEnforce(timerIdentifier: timerIdentifier) + } + + func evaluateCompliance( + isLookingAtScreen: Bool, + faceDetected: Bool + ) -> ComplianceResult { + guard faceDetected else { return .faceNotDetected } + return isLookingAtScreen ? .notCompliant : .compliant + } +} + +@MainActor +class EnforceCameraController: ObservableObject { + @Published private(set) var isCameraActive = false + @Published private(set) var lastFaceDetectionTime: Date = .distantPast + + weak var delegate: EnforceCameraControllerDelegate? + + private let eyeTrackingService: EyeTrackingService + private var cancellables = Set() + private var faceDetectionTimer: Timer? + var faceDetectionTimeout: TimeInterval = 5.0 + + init(eyeTrackingService: EyeTrackingService) { + self.eyeTrackingService = eyeTrackingService + setupObservers() + } + + func startCamera() async throws { + guard !isCameraActive else { return } + try await eyeTrackingService.startEyeTracking() + isCameraActive = true + lastFaceDetectionTime = Date() + startFaceDetectionTimer() + } + + func stopCamera() { + guard isCameraActive else { return } + eyeTrackingService.stopEyeTracking() + isCameraActive = false + stopFaceDetectionTimer() + } + + func resetFaceDetectionTimer() { + lastFaceDetectionTime = Date() + } + + private func setupObservers() { + eyeTrackingService.$userLookingAtScreen + .sink { [weak self] lookingAtScreen in + guard let self else { return } + self.delegate?.cameraController(self, didUpdateLookingAtScreen: lookingAtScreen) + } + .store(in: &cancellables) + + eyeTrackingService.$faceDetected + .sink { [weak self] faceDetected in + guard let self else { return } + if faceDetected { + self.lastFaceDetectionTime = Date() + } + } + .store(in: &cancellables) + } + + private func startFaceDetectionTimer() { + stopFaceDetectionTimer() + + faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + Task { @MainActor [weak self] in + self?.checkFaceDetectionTimeout() + } + } + + } + + private func stopFaceDetectionTimer() { + faceDetectionTimer?.invalidate() + faceDetectionTimer = nil + } + + private func checkFaceDetectionTimeout() { + guard isCameraActive else { + stopFaceDetectionTimer() + return + } + + let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime) + if timeSinceLastDetection > faceDetectionTimeout { + delegate?.cameraControllerDidTimeout(self) + } + } +} + @MainActor class EnforceModeService: ObservableObject { static let shared = EnforceModeService() diff --git a/Gaze/Services/ReminderManager.swift b/Gaze/Services/ReminderManager.swift deleted file mode 100644 index 4e5478d..0000000 --- a/Gaze/Services/ReminderManager.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// 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.timerIntervalMinutes(for: .lookAway) * 60) - 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/TimerManager.swift b/Gaze/Services/TimerManager.swift deleted file mode 100644 index c1c774d..0000000 --- a/Gaze/Services/TimerManager.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// 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 intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60 - if settingsProvider.isTimerEnabled(for: timerType) { - let identifier = TimerIdentifier.builtIn(timerType) - newStates[identifier] = TimerStateBuilder.make( - identifier: identifier, - intervalSeconds: intervalSeconds - ) - } - } - - // Add user timers (using unified approach) - for userTimer in settingsProvider.settings.userTimers where userTimer.enabled { - let identifier = TimerIdentifier.user(id: userTimer.id) - newStates[identifier] = TimerStateBuilder.make( - identifier: identifier, - intervalSeconds: userTimer.intervalMinutes * 60 - ) - } - - // 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 intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60 - let identifier = TimerIdentifier.builtIn(timerType) - - if settingsProvider.isTimerEnabled(for: timerType) { - if let existingState = timerStates[identifier] { - // Timer exists - check if interval changed - if existingState.originalIntervalSeconds != intervalSeconds { - // Interval changed - reset with new interval - var updatedState = existingState - updatedState.reset(intervalSeconds: intervalSeconds, keepPaused: true) - newStates[identifier] = updatedState - } else { - // Interval unchanged - keep existing state - newStates[identifier] = existingState - } - } else { - // Timer was just enabled - create new state - newStates[identifier] = TimerStateBuilder.make( - identifier: identifier, - intervalSeconds: intervalSeconds - ) - } - } - // If timer is disabled, 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 - var updatedState = existingState - updatedState.reset(intervalSeconds: newIntervalSeconds, keepPaused: true) - newStates[identifier] = updatedState - } else { - // Interval unchanged - keep existing state - newStates[identifier] = existingState - } - } else { - // New timer - create state - newStates[identifier] = TimerStateBuilder.make( - identifier: identifier, - intervalSeconds: newIntervalSeconds - ) - } - } - // 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(using: timeProvider) < 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) - - var updatedState = state - updatedState.reset(intervalSeconds: intervalSeconds, keepPaused: true) - timerStates[identifier] = updatedState - } - - /// Unified way to get interval for any timer type - private func getTimerInterval(for identifier: TimerIdentifier) -> Int { - switch identifier { - case .builtIn(let type): - return settingsProvider.timerIntervalMinutes(for: type) * 60 - 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 { - timerStates[identifier]?.remainingDuration ?? 0 - } - - func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String { - return getTimeRemaining(for: identifier).formatAsTimerDurationFull() - } - - func isTimerPaused(_ identifier: TimerIdentifier) -> Bool { - return timerStates[identifier]?.isPaused ?? true - } -} diff --git a/GazeTests/Services/EnforcePolicyEvaluatorTests.swift b/GazeTests/Services/EnforcePolicyEvaluatorTests.swift new file mode 100644 index 0000000..38fbd8e --- /dev/null +++ b/GazeTests/Services/EnforcePolicyEvaluatorTests.swift @@ -0,0 +1,217 @@ +// +// EnforcePolicyEvaluatorTests.swift +// GazeTests +// +// Unit tests for EnforcePolicyEvaluator (now nested in EnforceModeService). +// + +import XCTest +@testable import Gaze + +@MainActor +final class EnforcePolicyEvaluatorTests: XCTestCase { + + var evaluator: EnforcePolicyEvaluator! + var mockSettings: EnhancedMockSettingsManager! + + override func setUp() async throws { + mockSettings = EnhancedMockSettingsManager(settings: .defaults) + evaluator = EnforcePolicyEvaluator(settingsProvider: mockSettings) + } + + override func tearDown() async throws { + evaluator = nil + mockSettings = nil + } + + // MARK: - Initialization Tests + + func testInitialization() { + XCTAssertNotNil(evaluator) + } + + func testInitializationWithSettingsProvider() { + let newSettings = EnhancedMockSettingsManager(settings: AppSettings.defaults) + let newEvaluator = EnforcePolicyEvaluator(settingsProvider: newSettings) + XCTAssertNotNil(newEvaluator) + } + + // MARK: - Enforcement Enabled Tests + + func testIsEnforcementEnabledWhenLookAwayDisabled() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: false) + + let isEnabled = evaluator.isEnforcementEnabled + + XCTAssertFalse(isEnabled) + } + + func testIsEnforcementEnabledWhenLookAwayEnabled() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + let isEnabled = evaluator.isEnforcementEnabled + + XCTAssertTrue(isEnabled) + } + + // MARK: - Should Enforce Tests + + func testShouldEnforceWhenLookAwayEnabled() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.lookAway)) + + XCTAssertTrue(shouldEnforce) + } + + func testShouldEnforceWhenLookAwayDisabled() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: false) + + let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.lookAway)) + + XCTAssertFalse(shouldEnforce) + } + + func testShouldEnforceUserTimerNever() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .user) + + XCTAssertFalse(shouldEnforce) + } + + func testShouldEnforceBuiltInPostureTimerNever() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.posture)) + + XCTAssertFalse(shouldEnforce) + } + + func testShouldEnforceBuiltInBlinkTimerNever() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.blink)) + + XCTAssertFalse(shouldEnforce) + } + + // MARK: - Pre-activate Camera Tests + + func testShouldPreActivateCameraWhenTimerDisabled() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: false) + + let shouldPreActivate = evaluator.shouldPreActivateCamera( + timerIdentifier: .builtIn(.lookAway), + secondsRemaining: 3 + ) + + XCTAssertFalse(shouldPreActivate) + } + + func testShouldPreActivateCameraWhenSecondsRemainingTooHigh() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + let shouldPreActivate = evaluator.shouldPreActivateCamera( + timerIdentifier: .builtIn(.lookAway), + secondsRemaining: 5 + ) + + XCTAssertFalse(shouldPreActivate) + } + + func testShouldPreActivateCameraWhenAllConditionsMet() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + let shouldPreActivate = evaluator.shouldPreActivateCamera( + timerIdentifier: .builtIn(.lookAway), + secondsRemaining: 2 + ) + + XCTAssertTrue(shouldPreActivate) + } + + func testShouldPreActivateCameraForUserTimerNever() { + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + let shouldPreActivate = evaluator.shouldPreActivateCamera( + timerIdentifier: .user, + secondsRemaining: 1 + ) + + XCTAssertFalse(shouldPreActivate) + } + + // MARK: - Compliance Evaluation Tests + + func testEvaluateComplianceWhenLookingAtScreenAndFaceDetected() { + let result = evaluator.evaluateCompliance( + isLookingAtScreen: true, + faceDetected: true + ) + + XCTAssertEqual(result, .notCompliant) + } + + func testEvaluateComplianceWhenNotLookingAtScreenAndFaceDetected() { + let result = evaluator.evaluateCompliance( + isLookingAtScreen: false, + faceDetected: true + ) + + XCTAssertEqual(result, .compliant) + } + + func testEvaluateComplianceWhenFaceNotDetected() { + let result = evaluator.evaluateCompliance( + isLookingAtScreen: true, + faceDetected: false + ) + + XCTAssertEqual(result, .faceNotDetected) + } + + func testEvaluateComplianceWhenFaceNotDetectedAndNotLookingAtScreen() { + let result = evaluator.evaluateCompliance( + isLookingAtScreen: false, + faceDetected: false + ) + + XCTAssertEqual(result, .faceNotDetected) + } + + func testEvaluateComplianceWhenFaceNotDetectedAndNotLookingAtScreen() { + // Test edge case - should still return face not detected + let result = evaluator.evaluateCompliance( + isLookingAtScreen: false, + faceDetected: false + ) + + XCTAssertEqual(result, .faceNotDetected) + } + + // MARK: - Integration Tests + + func testFullEnforcementFlow() { + // Setup: Look away timer enabled + mockSettings.updateTimerEnabled(for: .lookAway, enabled: true) + + // Test 1: Check enforcement + let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.lookAway)) + XCTAssertTrue(shouldEnforce) + + // Test 2: Check pre-activation at 3 seconds + let shouldPreActivate = evaluator.shouldPreActivateCamera( + timerIdentifier: .builtIn(.lookAway), + secondsRemaining: 3 + ) + XCTAssertTrue(shouldPreActivate) + + // Test 3: Check compliance when looking at screen + let compliance = evaluator.evaluateCompliance( + isLookingAtScreen: true, + faceDetected: true + ) + XCTAssertEqual(compliance, .notCompliant) + } +} diff --git a/GazeTests/Services/TimerEngineTests.swift b/GazeTests/Services/TimerEngineTests.swift index c3367e6..413beb7 100644 --- a/GazeTests/Services/TimerEngineTests.swift +++ b/GazeTests/Services/TimerEngineTests.swift @@ -265,8 +265,8 @@ final class TimerEngineTests: XCTestCase { systemSleepManager.handleSystemWillSleep() - // States should still exist - XCTAssertEqual(timerEngine.timerStates.count, statesBefore) + // States should be cleared + XCTAssertEqual(timerEngine.timerStates.count, 0) } func testSystemSleepManagerHandlesWake() {