diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 4c098f8..8af6eb7 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -16,7 +16,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private var updateManager: UpdateManager? private var overlayReminderWindowController: NSWindowController? private var subtleReminderWindowController: NSWindowController? - private var settingsWindowController: NSWindowController? private var cancellables = Set() private var hasStartedTimers = false @@ -271,120 +270,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { subtleReminderWindowController = nil } - // Public method to open settings window func openSettings(tab: Int = 0) { - // Post notification to close menu bar popover - NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) + handleMenuDismissal() - // Dismiss overlay reminders to prevent them from blocking settings window - // Overlay reminders are at .floating level which would sit above settings - dismissOverlayReminder() - - // Small delay to allow menu bar to close before opening settings DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.openSettingsWindow(tab: tab) + guard let self else { return } + SettingsWindowPresenter.shared.show(settingsManager: self.settingsManager, initialTab: tab) } } - // Public method to reopen onboarding window func openOnboarding() { - NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) - - // Dismiss overlay reminders to prevent blocking onboarding window - dismissOverlayReminder() + handleMenuDismissal() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - guard let self = self else { return } - - 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) + guard let self else { return } + OnboardingWindowPresenter.shared.show(settingsManager: self.settingsManager) } } - private func openSettingsWindow(tab: Int) { - if let existingWindow = findWindow(withIdentifier: WindowIdentifiers.settings) { - NotificationCenter.default.post( - name: Notification.Name("SwitchToSettingsTab"), - object: tab - ) - existingWindow.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - return - } - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - - window.identifier = WindowIdentifiers.settings - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.toolbarStyle = .unified - window.toolbar = NSToolbar() - window.center() - window.setFrameAutosaveName("SettingsWindow") - window.isReleasedWhenClosed = false - - window.contentView = NSHostingView( - rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: tab) - ) - - let windowController = NSWindowController(window: window) - windowController.showWindow(nil) - - settingsWindowController = windowController - - NSApp.activate(ignoringOtherApps: true) - - NotificationCenter.default.addObserver( - self, - selector: #selector(settingsWindowWillCloseNotification(_:)), - name: NSWindow.willCloseNotification, - object: window - ) + private func handleMenuDismissal() { + NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) + dismissOverlayReminder() } - @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/EyeTrackingConstants.swift b/Gaze/Constants/EyeTrackingConstants.swift index c55bdd5..6ff03ea 100644 --- a/Gaze/Constants/EyeTrackingConstants.swift +++ b/Gaze/Constants/EyeTrackingConstants.swift @@ -5,9 +5,12 @@ // Created by Mike Freno on 1/14/26. // +import Combine import Foundation -enum EyeTrackingConstants { +class EyeTrackingConstants: ObservableObject { + static let shared = EyeTrackingConstants() + // MARK: - Logging /// Interval between log messages in seconds static let logInterval: TimeInterval = 0.5 @@ -15,29 +18,57 @@ enum EyeTrackingConstants { // MARK: - Eye Closure Detection /// Threshold for eye closure (smaller value means eye must be more closed to trigger) /// Range: 0.0 to 1.0 (approximate eye opening ratio) - static let eyeClosedThreshold: CGFloat = 0.02 + @Published var eyeClosedThreshold: CGFloat = 0.02 + @Published var eyeClosedEnabled: Bool = true // MARK: - Face Pose Thresholds /// Maximum yaw (left/right head turn) in radians before considering user looking away /// 0.20 radians โ‰ˆ 11.5 degrees (Tightened from 0.35) - static let yawThreshold: Double = 0.2 + /// NOTE: Vision Framework often provides unreliable yaw/pitch on macOS - disabled by default + @Published var yawThreshold: Double = 0.3 + @Published var yawEnabled: Bool = false /// Pitch threshold for looking UP (above screen). /// Since camera is at top, looking at screen is negative pitch. /// Values > 0.1 imply looking straight ahead or up (away from screen). - static let pitchUpThreshold: Double = 0.1 + /// NOTE: Vision Framework often doesn't provide pitch data on macOS - disabled by default + @Published var pitchUpThreshold: Double = 0.1 + @Published var pitchUpEnabled: Bool = false /// Pitch threshold for looking DOWN (at keyboard/lap). /// Values < -0.45 imply looking too far down. - static let pitchDownThreshold: Double = -0.45 + /// NOTE: Vision Framework often doesn't provide pitch data on macOS - disabled by default + @Published var pitchDownThreshold: Double = -0.45 + @Published var pitchDownEnabled: Bool = false // MARK: - Pupil Tracking Thresholds /// Minimum horizontal pupil ratio (0.0 = right edge, 1.0 = left edge) /// Values below this are considered looking right (camera view) - static let minPupilRatio: Double = 0.40 + /// Tightened to 0.35 based on observed values (typically 0.31-0.47) + @Published var minPupilRatio: Double = 0.35 + @Published var minPupilEnabled: Bool = true /// Maximum horizontal pupil ratio /// Values above this are considered looking left (camera view) - /// Tightened from 0.75 to 0.65 - static let maxPupilRatio: Double = 0.6 + /// Tightened to 0.45 based on observed values (typically 0.31-0.47) + @Published var maxPupilRatio: Double = 0.45 + @Published var maxPupilEnabled: Bool = true + + private init() {} + + // MARK: - Reset to Defaults + func resetToDefaults() { + eyeClosedThreshold = 0.02 + eyeClosedEnabled = true + yawThreshold = 0.3 + yawEnabled = false // Disabled by default - Vision Framework unreliable on macOS + pitchUpThreshold = 0.1 + pitchUpEnabled = false // Disabled by default - often not available on macOS + pitchDownThreshold = -0.45 + pitchDownEnabled = false // Disabled by default - often not available on macOS + minPupilRatio = 0.35 + minPupilEnabled = true + maxPupilRatio = 0.45 + maxPupilEnabled = true + } } diff --git a/Gaze/Protocols/EnforceModeProviding.swift b/Gaze/Protocols/EnforceModeProviding.swift new file mode 100644 index 0000000..8e05e79 --- /dev/null +++ b/Gaze/Protocols/EnforceModeProviding.swift @@ -0,0 +1,57 @@ +// +// EnforceModeProviding.swift +// Gaze +// +// Protocol abstraction for EnforceModeService to enable dependency injection and testing. +// + +import Combine +import Foundation + +/// Protocol that defines the interface for enforce mode functionality. +@MainActor +protocol EnforceModeProviding: AnyObject, ObservableObject { + /// Whether enforce mode is currently enabled + var isEnforceModeEnabled: Bool { get } + + /// Whether the camera is currently active + var isCameraActive: Bool { get } + + /// Whether the user has complied with the break + var userCompliedWithBreak: Bool { get } + + /// Whether we're in test mode + var isTestMode: Bool { get } + + /// Enables enforce mode (may request camera permission) + func enableEnforceMode() async + + /// Disables enforce mode + func disableEnforceMode() + + /// Sets the timer engine reference + func setTimerEngine(_ engine: TimerEngine) + + /// Checks if a break should be enforced for the given timer + func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool + + /// Starts the camera for lookaway timer + func startCameraForLookawayTimer(secondsRemaining: Int) async + + /// Stops the camera + func stopCamera() + + /// Checks if user is complying with the break + func checkUserCompliance() + + /// Handles reminder dismissal + func handleReminderDismissed() + + /// Starts test mode + func startTestMode() async + + /// Stops test mode + func stopTestMode() +} + +extension EnforceModeService: EnforceModeProviding {} diff --git a/Gaze/Protocols/SettingsProviding.swift b/Gaze/Protocols/SettingsProviding.swift new file mode 100644 index 0000000..e652cd5 --- /dev/null +++ b/Gaze/Protocols/SettingsProviding.swift @@ -0,0 +1,48 @@ +// +// SettingsProviding.swift +// Gaze +// +// Protocol abstraction for SettingsManager to enable dependency injection and testing. +// + +import Combine +import Foundation + +/// Protocol that defines the interface for managing application settings. +/// This abstraction allows for dependency injection and easy mocking in tests. +@MainActor +protocol SettingsProviding: AnyObject, ObservableObject { + /// The current application settings + var settings: AppSettings { get set } + + /// Publisher for observing settings changes + var settingsPublisher: Published.Publisher { get } + + /// Retrieves the timer configuration for a specific timer type + func timerConfiguration(for type: TimerType) -> TimerConfiguration + + /// Updates the timer configuration for a specific timer type + func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) + + /// Returns all timer configurations + func allTimerConfigurations() -> [TimerType: TimerConfiguration] + + /// Saves settings to persistent storage + func save() + + /// Forces immediate save + func saveImmediately() + + /// Loads settings from persistent storage + func load() + + /// Resets settings to default values + func resetToDefaults() +} + +/// Extension to provide the publisher for SettingsManager +extension SettingsManager: SettingsProviding { + var settingsPublisher: Published.Publisher { + $settings + } +} diff --git a/Gaze/Protocols/SmartModeProviding.swift b/Gaze/Protocols/SmartModeProviding.swift new file mode 100644 index 0000000..57de3f9 --- /dev/null +++ b/Gaze/Protocols/SmartModeProviding.swift @@ -0,0 +1,52 @@ +// +// SmartModeProviding.swift +// Gaze +// +// Protocols for Smart Mode services (Fullscreen Detection, Idle Monitoring). +// + +import Combine +import Foundation + +/// Protocol for fullscreen detection functionality +@MainActor +protocol FullscreenDetectionProviding: AnyObject, ObservableObject { + /// Whether a fullscreen app is currently active + var isFullscreenActive: Bool { get } + + /// Publisher for fullscreen state changes + var isFullscreenActivePublisher: Published.Publisher { get } + + /// Forces an immediate state update + func forceUpdate() +} + +/// Protocol for idle monitoring functionality +@MainActor +protocol IdleMonitoringProviding: AnyObject, ObservableObject { + /// Whether the user is currently idle + var isIdle: Bool { get } + + /// Publisher for idle state changes + var isIdlePublisher: Published.Publisher { get } + + /// Updates the idle threshold + func updateThreshold(minutes: Int) + + /// Forces an immediate state update + func forceUpdate() +} + +// MARK: - Extensions for conformance + +extension FullscreenDetectionService: FullscreenDetectionProviding { + var isFullscreenActivePublisher: Published.Publisher { + $isFullscreenActive + } +} + +extension IdleMonitoringService: IdleMonitoringProviding { + var isIdlePublisher: Published.Publisher { + $isIdle + } +} diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift index 00f05e5..c36f2b4 100644 --- a/Gaze/Services/EnforceModeService.swift +++ b/Gaze/Services/EnforceModeService.swift @@ -11,35 +11,35 @@ import Foundation @MainActor class EnforceModeService: ObservableObject { static let shared = EnforceModeService() - + @Published var isEnforceModeEnabled = false @Published var isCameraActive = false @Published var userCompliedWithBreak = false @Published var isTestMode = false - + private var settingsManager: SettingsManager private var eyeTrackingService: EyeTrackingService private var timerEngine: TimerEngine? - + private var cancellables = Set() private var faceDetectionTimer: Timer? private var lastFaceDetectionTime: Date = Date.distantPast - private let faceDetectionTimeout: TimeInterval = 5.0 // 5 seconds to consider person lost - + private let faceDetectionTimeout: TimeInterval = 5.0 // 5 seconds to consider person lost + private init() { self.settingsManager = SettingsManager.shared self.eyeTrackingService = EyeTrackingService.shared setupObservers() initializeEnforceModeState() } - + private func setupObservers() { eyeTrackingService.$userLookingAtScreen .sink { [weak self] lookingAtScreen in self?.handleGazeChange(lookingAtScreen: lookingAtScreen) } .store(in: &cancellables) - + // Observe face detection changes to track person presence eyeTrackingService.$faceDetected .sink { [weak self] faceDetected in @@ -47,11 +47,11 @@ class EnforceModeService: ObservableObject { } .store(in: &cancellables) } - + private func initializeEnforceModeState() { let cameraService = CameraAccessService.shared let settingsEnabled = settingsManager.settings.enforcementMode - + // If settings say it's enabled AND camera is authorized, mark as enabled if settingsEnabled && cameraService.isCameraAuthorized { isEnforceModeEnabled = true @@ -61,14 +61,14 @@ class EnforceModeService: ObservableObject { print("๐Ÿ”’ Enforce mode initialized as disabled") } } - + func enableEnforceMode() async { print("๐Ÿ”’ enableEnforceMode called") guard !isEnforceModeEnabled else { print("โš ๏ธ Enforce mode already enabled") return } - + let cameraService = CameraAccessService.shared if !cameraService.isCameraAuthorized { do { @@ -79,33 +79,33 @@ class EnforceModeService: ObservableObject { return } } - + guard cameraService.isCameraAuthorized else { print("โŒ Camera permission denied") return } - + isEnforceModeEnabled = true print("โœ“ Enforce mode enabled (camera will activate before lookaway reminders)") } - + func disableEnforceMode() { guard isEnforceModeEnabled else { return } - + stopCamera() isEnforceModeEnabled = false userCompliedWithBreak = false print("โœ“ Enforce mode disabled") } - + func setTimerEngine(_ engine: TimerEngine) { self.timerEngine = engine } - + func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool { guard isEnforceModeEnabled else { return false } guard settingsManager.settings.enforcementMode else { return false } - + switch timerIdentifier { case .builtIn(let type): return type == .lookAway @@ -113,88 +113,91 @@ class EnforceModeService: ObservableObject { return false } } - + func startCameraForLookawayTimer(secondsRemaining: Int) async { guard isEnforceModeEnabled else { return } guard !isCameraActive else { return } - + print("๐Ÿ‘๏ธ Starting camera for lookaway reminder (T-\(secondsRemaining)s)") - + do { try await eyeTrackingService.startEyeTracking() isCameraActive = true - lastFaceDetectionTime = Date() // Reset grace period + lastFaceDetectionTime = Date() // Reset grace period startFaceDetectionTimer() print("โœ“ Camera active") } catch { print("โš ๏ธ Failed to start camera: \(error.localizedDescription)") } } - + func stopCamera() { guard isCameraActive else { return } - + print("๐Ÿ‘๏ธ Stopping camera") eyeTrackingService.stopEyeTracking() isCameraActive = false userCompliedWithBreak = false - + stopFaceDetectionTimer() } - + func checkUserCompliance() { guard isCameraActive else { userCompliedWithBreak = false return } - + let lookingAway = !eyeTrackingService.userLookingAtScreen userCompliedWithBreak = lookingAway } - + private func handleGazeChange(lookingAtScreen: Bool) { guard isCameraActive else { return } - + checkUserCompliance() } - + private func handleFaceDetectionChange(faceDetected: Bool) { // Update the last face detection time only when a face is actively detected if faceDetected { lastFaceDetectionTime = Date() } } - + private func startFaceDetectionTimer() { stopFaceDetectionTimer() // Check every 1 second - faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { + [weak self] _ in Task { @MainActor [weak self] in self?.checkFaceDetectionTimeout() } } } - + private func stopFaceDetectionTimer() { faceDetectionTimer?.invalidate() faceDetectionTimer = nil } - + private func checkFaceDetectionTimeout() { guard isEnforceModeEnabled && isCameraActive else { stopFaceDetectionTimer() return } - + let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime) - + // If person has not been detected for too long, temporarily disable enforce mode if timeSinceLastDetection > faceDetectionTimeout { - print("โฐ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode.") + print( + "โฐ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode." + ) disableEnforceMode() } } - + func handleReminderDismissed() { // Stop camera when reminder is dismissed, but also check if we should disable enforce mode entirely // This helps in case a user closes settings window while a reminder is active @@ -202,18 +205,18 @@ class EnforceModeService: ObservableObject { stopCamera() } } - + func startTestMode() async { guard isEnforceModeEnabled else { return } guard !isCameraActive else { return } - + print("๐Ÿงช Starting test mode") isTestMode = true - + do { try await eyeTrackingService.startEyeTracking() isCameraActive = true - lastFaceDetectionTime = Date() // Reset grace period + lastFaceDetectionTime = Date() // Reset grace period startFaceDetectionTimer() print("โœ“ Test mode camera active") } catch { @@ -221,12 +224,13 @@ class EnforceModeService: ObservableObject { isTestMode = false } } - + func stopTestMode() { guard isTestMode else { return } - + print("๐Ÿงช Stopping test mode") stopCamera() isTestMode = false } -} \ No newline at end of file +} + diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index e5f3ce7..c4d6ba3 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -8,6 +8,7 @@ import AVFoundation import Combine import Vision +import simd @MainActor class EyeTrackingService: NSObject, ObservableObject { @@ -18,6 +19,16 @@ class EyeTrackingService: NSObject, ObservableObject { @Published var userLookingAtScreen = true @Published var faceDetected = false + // Debug properties for UI display + @Published var debugLeftPupilRatio: Double? + @Published var debugRightPupilRatio: Double? + @Published var debugYaw: Double? + @Published var debugPitch: Double? + @Published var enableDebugLogging: Bool = false + + // Throttle for debug logging + private var lastDebugLogTime: Date = .distantPast + private var captureSession: AVCaptureSession? private var videoOutput: AVCaptureVideoDataOutput? private let videoDataOutputQueue = DispatchQueue( @@ -116,7 +127,7 @@ class EyeTrackingService: NSObject, ObservableObject { self.videoOutput = output } - private func processFaceObservations(_ observations: [VNFaceObservation]?) { + private func processFaceObservations(_ observations: [VNFaceObservation]?, imageSize: CGSize) { guard let observations = observations, !observations.isEmpty else { faceDetected = false userLookingAtScreen = false @@ -126,10 +137,26 @@ class EyeTrackingService: NSObject, ObservableObject { faceDetected = true let face = observations.first! + if enableDebugLogging { + print("๐Ÿ‘๏ธ Face observation - boundingBox: \(face.boundingBox)") + print( + "๐Ÿ‘๏ธ Yaw: \(face.yaw?.doubleValue ?? 999), Pitch: \(face.pitch?.doubleValue ?? 999), Roll: \(face.roll?.doubleValue ?? 999)" + ) + } + guard let landmarks = face.landmarks else { + if enableDebugLogging { + print("๐Ÿ‘๏ธ No landmarks available") + } return } + if enableDebugLogging { + print( + "๐Ÿ‘๏ธ Landmarks - leftEye: \(landmarks.leftEye != nil), rightEye: \(landmarks.rightEye != nil), leftPupil: \(landmarks.leftPupil != nil), rightPupil: \(landmarks.rightPupil != nil)" + ) + } + // Check eye closure if let leftEye = landmarks.leftEye, let rightEye = landmarks.rightEye @@ -140,13 +167,25 @@ class EyeTrackingService: NSObject, ObservableObject { } // Check gaze direction - let lookingAway = detectLookingAway(face: face, landmarks: landmarks, shouldLog: false) + let lookingAway = detectLookingAway( + face: face, + landmarks: landmarks, + imageSize: imageSize, + shouldLog: enableDebugLogging + ) userLookingAtScreen = !lookingAway } private func detectEyesClosed( leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D, shouldLog: Bool ) -> Bool { + let constants = EyeTrackingConstants.shared + + // If eye closure detection is disabled, always return false (eyes not closed) + guard constants.eyeClosedEnabled else { + return false + } + guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { return false } @@ -154,7 +193,7 @@ class EyeTrackingService: NSObject, ObservableObject { let leftEyeHeight = calculateEyeHeight(leftEye, shouldLog: shouldLog) let rightEyeHeight = calculateEyeHeight(rightEye, shouldLog: shouldLog) - let closedThreshold = EyeTrackingConstants.eyeClosedThreshold + let closedThreshold = constants.eyeClosedThreshold let isClosed = leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold @@ -175,22 +214,57 @@ class EyeTrackingService: NSObject, ObservableObject { } private func detectLookingAway( - face: VNFaceObservation, landmarks: VNFaceLandmarks2D, shouldLog: Bool + face: VNFaceObservation, landmarks: VNFaceLandmarks2D, imageSize: CGSize, shouldLog: Bool ) -> Bool { + let constants = EyeTrackingConstants.shared + // 1. Face Pose Check (Yaw & Pitch) let yaw = face.yaw?.doubleValue ?? 0.0 let pitch = face.pitch?.doubleValue ?? 0.0 + let roll = face.roll?.doubleValue ?? 0.0 - let yawThreshold = EyeTrackingConstants.yawThreshold - // Pitch check: - // - Camera at top = looking at screen is negative pitch - // - Looking above screen (straight ahead) is ~0 or positive -> Look Away - // - Looking at keyboard/lap is very negative -> Look Away - let pitchLookingAway = - pitch > EyeTrackingConstants.pitchUpThreshold - || pitch < EyeTrackingConstants.pitchDownThreshold + // Debug logging + if shouldLog { + print("๐Ÿ‘๏ธ Face Pose - Yaw: \(yaw), Pitch: \(pitch), Roll: \(roll)") + print( + "๐Ÿ‘๏ธ Face available data - hasYaw: \(face.yaw != nil), hasPitch: \(face.pitch != nil), hasRoll: \(face.roll != nil)" + ) + } - let poseLookingAway = abs(yaw) > yawThreshold || pitchLookingAway + // Update debug values + Task { @MainActor in + debugYaw = yaw + debugPitch = pitch + } + + var poseLookingAway = false + + // Only use yaw/pitch if they're actually available and enabled + // Note: Vision Framework on macOS often doesn't provide reliable pitch data + if face.pitch != nil { + // Check yaw if enabled + if constants.yawEnabled { + let yawThreshold = constants.yawThreshold + if abs(yaw) > yawThreshold { + poseLookingAway = true + } + } + + // Check pitch if either threshold is enabled + if !poseLookingAway { + var pitchLookingAway = false + + if constants.pitchUpEnabled && pitch > constants.pitchUpThreshold { + pitchLookingAway = true + } + + if constants.pitchDownEnabled && pitch < constants.pitchDownThreshold { + pitchLookingAway = true + } + + poseLookingAway = pitchLookingAway + } + } // 2. Eye Gaze Check (Pupil Position) var eyesLookingAway = false @@ -200,22 +274,92 @@ class EyeTrackingService: NSObject, ObservableObject { let leftPupil = landmarks.leftPupil, let rightPupil = landmarks.rightPupil { + + // NEW: Use inter-eye distance method + let gazeOffsets = calculateGazeUsingInterEyeDistance( + leftEye: leftEye, + rightEye: rightEye, + leftPupil: leftPupil, + rightPupil: rightPupil, + imageSize: imageSize, + faceBoundingBox: face.boundingBox + ) - let leftRatio = calculatePupilHorizontalRatio(eye: leftEye, pupil: leftPupil) - let rightRatio = calculatePupilHorizontalRatio(eye: rightEye, pupil: rightPupil) + let leftRatio = calculatePupilHorizontalRatio( + eye: leftEye, + pupil: leftPupil, + imageSize: imageSize, + faceBoundingBox: face.boundingBox + ) + let rightRatio = calculatePupilHorizontalRatio( + eye: rightEye, + pupil: rightPupil, + imageSize: imageSize, + faceBoundingBox: face.boundingBox + ) + + // Debug logging + if shouldLog { + print( + "๐Ÿ‘๏ธ Pupil Ratios (OLD METHOD) - Left: \(String(format: "%.3f", leftRatio)), Right: \(String(format: "%.3f", rightRatio))" + ) + print( + "๐Ÿ‘๏ธ Gaze Offsets (NEW METHOD) - Left: \(String(format: "%.3f", gazeOffsets.leftGaze)), Right: \(String(format: "%.3f", gazeOffsets.rightGaze))" + ) + print( + "๐Ÿ‘๏ธ Thresholds - Min: \(constants.minPupilRatio), Max: \(constants.maxPupilRatio)" + ) + } + + // Update debug values + Task { @MainActor in + debugLeftPupilRatio = leftRatio + debugRightPupilRatio = rightRatio + } // Normal range for "looking center" is roughly 0.3 to 0.7 // (0.0 = extreme right, 1.0 = extreme left relative to face) // Note: Camera is mirrored, so logic might be inverted - let minRatio = EyeTrackingConstants.minPupilRatio - let maxRatio = EyeTrackingConstants.maxPupilRatio + var leftLookingAway = false + var rightLookingAway = false - let leftLookingAway = leftRatio < minRatio || leftRatio > maxRatio - let rightLookingAway = rightRatio < minRatio || rightRatio > maxRatio + // Check min pupil ratio if enabled + /*if constants.minPupilEnabled {*/ + /*let minRatio = constants.minPupilRatio*/ + /*if leftRatio < minRatio {*/ + /*leftLookingAway = true*/ + /*}*/ + /*if rightRatio < minRatio {*/ + /*rightLookingAway = true*/ + /*}*/ + /*}*/ - // Consider looking away if BOTH eyes are off-center - eyesLookingAway = leftLookingAway && rightLookingAway + /*// Check max pupil ratio if enabled*/ + /*if constants.maxPupilEnabled {*/ + /*let maxRatio = constants.maxPupilRatio*/ + /*if leftRatio > maxRatio {*/ + /*leftLookingAway = true*/ + /*}*/ + /*if rightRatio > maxRatio {*/ + /*rightLookingAway = true*/ + /*}*/ + /*}*/ + + // Consider looking away if EITHER eye is off-center + // Changed from AND to OR logic because requiring both eyes makes detection too restrictive + // This is more sensitive but also more reliable for detecting actual looking away + eyesLookingAway = leftLookingAway || rightLookingAway + + if shouldLog { + print( + "๐Ÿ‘๏ธ Looking Away - Left: \(leftLookingAway), Right: \(rightLookingAway), Either: \(eyesLookingAway)" + ) + } + } else { + if shouldLog { + print("๐Ÿ‘๏ธ Missing pupil or eye landmarks!") + } } let isLookingAway = poseLookingAway || eyesLookingAway @@ -224,34 +368,228 @@ class EyeTrackingService: NSObject, ObservableObject { } private func calculatePupilHorizontalRatio( - eye: VNFaceLandmarkRegion2D, pupil: VNFaceLandmarkRegion2D + eye: VNFaceLandmarkRegion2D, + pupil: VNFaceLandmarkRegion2D, + imageSize: CGSize, + faceBoundingBox: CGRect ) -> Double { + // Use normalizedPoints which are already normalized to face bounding box let eyePoints = eye.normalizedPoints let pupilPoints = pupil.normalizedPoints + // Throttle debug logging to every 0.5 seconds + let now = Date() + let shouldLog = now.timeIntervalSince(lastDebugLogTime) >= 0.5 + + if shouldLog { + lastDebugLogTime = now + + print("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + print("๐Ÿ“Š EYE TRACKING DEBUG DATA") + print("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + + print("\n๐Ÿ–ผ๏ธ IMAGE SIZE:") + print(" Width: \(imageSize.width), Height: \(imageSize.height)") + + print("\n๐Ÿ“ฆ FACE BOUNDING BOX (normalized):") + print(" Origin: (\(faceBoundingBox.origin.x), \(faceBoundingBox.origin.y))") + print(" Size: (\(faceBoundingBox.size.width), \(faceBoundingBox.size.height))") + + print("\n๐Ÿ‘๏ธ EYE LANDMARK POINTS (normalized to face bounding box - from Vision):") + print(" Count: \(eyePoints.count)") + let eyeMinX = eyePoints.min(by: { $0.x < $1.x })?.x ?? 0 + let eyeMaxX = eyePoints.max(by: { $0.x < $1.x })?.x ?? 0 + for (index, point) in eyePoints.enumerated() { + var marker = "" + if abs(point.x - eyeMinX) < 0.0001 { + marker = " โ† LEFTMOST (inner corner)" + } else if abs(point.x - eyeMaxX) < 0.0001 { + marker = " โ† RIGHTMOST (outer corner)" + } + if index == 0 { + marker += " [FIRST]" + } else if index == eyePoints.count - 1 { + marker += " [LAST]" + } + print( + " [\(index)]: (\(String(format: "%.4f", point.x)), \(String(format: "%.4f", point.y)))\(marker)" + ) + } + + print("\n๐Ÿ‘๏ธ PUPIL LANDMARK POINTS (normalized to face bounding box - from Vision):") + print(" Count: \(pupilPoints.count)") + for (index, point) in pupilPoints.enumerated() { + print( + " [\(index)]: (\(String(format: "%.4f", point.x)), \(String(format: "%.4f", point.y)))" + ) + } + + if let minPoint = eyePoints.min(by: { $0.x < $1.x }), + let maxPoint = eyePoints.max(by: { $0.x < $1.x }) + { + let eyeMinX = minPoint.x + let eyeMaxX = maxPoint.x + let eyeWidth = eyeMaxX - eyeMinX + let pupilCenterX = pupilPoints.map { $0.x }.reduce(0, +) / Double(pupilPoints.count) + let ratio = (pupilCenterX - eyeMinX) / eyeWidth + + print("\n๐Ÿ“ CALCULATIONS:") + print(" Eye MinX: \(String(format: "%.4f", eyeMinX))") + print(" Eye MaxX: \(String(format: "%.4f", eyeMaxX))") + print(" Eye Width: \(String(format: "%.4f", eyeWidth))") + + // Analyze different point pairs to find better eye width + if eyePoints.count >= 6 { + let cornerWidth = eyePoints[5].x - eyePoints[0].x + print(" Corner-to-Corner Width [0โ†’5]: \(String(format: "%.4f", cornerWidth))") + + // Try middle points too + if eyePoints.count >= 4 { + let midWidth = eyePoints[3].x - eyePoints[0].x + print(" Point [0โ†’3] Width: \(String(format: "%.4f", midWidth))") + } + } + + print(" Pupil Center X: \(String(format: "%.4f", pupilCenterX))") + print(" Pupil Min X: \(String(format: "%.4f", pupilPoints.min(by: { $0.x < $1.x })?.x ?? 0))") + print(" Pupil Max X: \(String(format: "%.4f", pupilPoints.max(by: { $0.x < $1.x })?.x ?? 0))") + print(" Final Ratio (current method): \(String(format: "%.4f", ratio))") + + // Calculate alternate ratios + if eyePoints.count >= 6 { + let cornerWidth = eyePoints[5].x - eyePoints[0].x + if cornerWidth > 0 { + let cornerRatio = (pupilCenterX - eyePoints[0].x) / cornerWidth + print(" Alternate Ratio (using corners [0โ†’5]): \(String(format: "%.4f", cornerRatio))") + } + } + } + + print("\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n") + } + guard !eyePoints.isEmpty, !pupilPoints.isEmpty else { return 0.5 } - // Get eye horizontal bounds - let eyeMinX = eyePoints.map { $0.x }.min() ?? 0 - let eyeMaxX = eyePoints.map { $0.x }.max() ?? 0 + guard let minPoint = eyePoints.min(by: { $0.x < $1.x }), + let maxPoint = eyePoints.max(by: { $0.x < $1.x }) + else { + return 0.5 + } + + let eyeMinX = minPoint.x + let eyeMaxX = maxPoint.x let eyeWidth = eyeMaxX - eyeMinX guard eyeWidth > 0 else { return 0.5 } - // Get pupil center X let pupilCenterX = pupilPoints.map { $0.x }.reduce(0, +) / Double(pupilPoints.count) - // Calculate ratio (0.0 to 1.0) - // 0.0 = Right side of eye (camera view) - // 1.0 = Left side of eye (camera view) + // Calculate ratio (0.0 to 1.0) - already normalized to face bounding box by Vision let ratio = (pupilCenterX - eyeMinX) / eyeWidth return ratio } + + /// NEW APPROACH: Calculate gaze using inter-eye distance as reference + /// This works around Vision's limitation that eye landmarks only track the iris, not true eye corners + private func calculateGazeUsingInterEyeDistance( + leftEye: VNFaceLandmarkRegion2D, + rightEye: VNFaceLandmarkRegion2D, + leftPupil: VNFaceLandmarkRegion2D, + rightPupil: VNFaceLandmarkRegion2D, + imageSize: CGSize, + faceBoundingBox: CGRect + ) -> (leftGaze: Double, rightGaze: Double) { + + // CRITICAL: Convert from face-normalized coordinates to image coordinates + // normalizedPoints are relative to face bounding box, not stable for gaze tracking + + // Helper to convert face-normalized point to image coordinates + func toImageCoords(_ point: CGPoint) -> CGPoint { + // Face bounding box origin is in Vision coordinates (bottom-left origin) + let imageX = faceBoundingBox.origin.x + point.x * faceBoundingBox.width + let imageY = faceBoundingBox.origin.y + point.y * faceBoundingBox.height + return CGPoint(x: imageX, y: imageY) + } + + // Convert all points to image space + let leftEyePointsImg = leftEye.normalizedPoints.map { toImageCoords($0) } + let rightEyePointsImg = rightEye.normalizedPoints.map { toImageCoords($0) } + let leftPupilPointsImg = leftPupil.normalizedPoints.map { toImageCoords($0) } + let rightPupilPointsImg = rightPupil.normalizedPoints.map { toImageCoords($0) } + + // Calculate eye centers (average of all iris boundary points) + let leftEyeCenterX = leftEyePointsImg.map { $0.x }.reduce(0, +) / Double(leftEyePointsImg.count) + let rightEyeCenterX = rightEyePointsImg.map { $0.x }.reduce(0, +) / Double(rightEyePointsImg.count) + + // Calculate pupil centers + let leftPupilX = leftPupilPointsImg.map { $0.x }.reduce(0, +) / Double(leftPupilPointsImg.count) + let rightPupilX = rightPupilPointsImg.map { $0.x }.reduce(0, +) / Double(rightPupilPointsImg.count) + + // Inter-eye distance (the distance between eye centers) - should be stable now + let interEyeDistance = abs(rightEyeCenterX - leftEyeCenterX) + + // Estimate iris width as a fraction of inter-eye distance + // Typical human: inter-pupil distance ~63mm, iris width ~12mm โ†’ ratio ~1/5 + let irisWidth = interEyeDistance / 5.0 + + // Calculate gaze offset for each eye (positive = looking right, negative = looking left) + let leftGazeOffset = (leftPupilX - leftEyeCenterX) / irisWidth + let rightGazeOffset = (rightPupilX - rightEyeCenterX) / irisWidth + + // Throttle debug logging + let now = Date() + let shouldLog = now.timeIntervalSince(lastDebugLogTime) >= 0.5 + + if shouldLog { + lastDebugLogTime = now + + print("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + print("๐Ÿ“Š INTER-EYE DISTANCE GAZE (IMAGE COORDS)") + print("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + + print("\n๐Ÿ–ผ๏ธ IMAGE SPACE:") + print(" Image Size: \(Int(imageSize.width)) x \(Int(imageSize.height))") + print(" Face Box: x=\(String(format: "%.3f", faceBoundingBox.origin.x)) w=\(String(format: "%.3f", faceBoundingBox.width))") + + print("\n๐Ÿ‘๏ธ EYE CENTERS (image coords):") + print(" Left Eye Center X: \(String(format: "%.4f", leftEyeCenterX)) (\(Int(leftEyeCenterX * imageSize.width))px)") + print(" Right Eye Center X: \(String(format: "%.4f", rightEyeCenterX)) (\(Int(rightEyeCenterX * imageSize.width))px)") + print(" Inter-Eye Distance: \(String(format: "%.4f", interEyeDistance)) (\(Int(interEyeDistance * imageSize.width))px)") + print(" Estimated Iris Width: \(String(format: "%.4f", irisWidth)) (\(Int(irisWidth * imageSize.width))px)") + + print("\n๐Ÿ‘๏ธ PUPIL POSITIONS (image coords):") + print(" Left Pupil X: \(String(format: "%.4f", leftPupilX)) (\(Int(leftPupilX * imageSize.width))px)") + print(" Right Pupil X: \(String(format: "%.4f", rightPupilX)) (\(Int(rightPupilX * imageSize.width))px)") + + print("\n๐Ÿ“ PUPIL OFFSETS FROM EYE CENTER:") + print(" Left Offset: \(String(format: "%.4f", leftPupilX - leftEyeCenterX)) (\(Int((leftPupilX - leftEyeCenterX) * imageSize.width))px)") + print(" Right Offset: \(String(format: "%.4f", rightPupilX - rightEyeCenterX)) (\(Int((rightPupilX - rightEyeCenterX) * imageSize.width))px)") + + print("\n๐Ÿ“ GAZE OFFSETS (normalized to iris width):") + print(" Left Gaze Offset: \(String(format: "%.4f", leftGazeOffset)) (0=center, +right, -left)") + print(" Right Gaze Offset: \(String(format: "%.4f", rightGazeOffset)) (0=center, +right, -left)") + print(" Average Gaze: \(String(format: "%.4f", (leftGazeOffset + rightGazeOffset) / 2))") + + // Interpretation + let avgGaze = (leftGazeOffset + rightGazeOffset) / 2 + var interpretation = "" + if avgGaze < -0.5 { + interpretation = "Looking LEFT" + } else if avgGaze > 0.5 { + interpretation = "Looking RIGHT" + } else { + interpretation = "Looking CENTER" + } + print(" Interpretation: \(interpretation)") + + print("\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n") + } + + return (leftGazeOffset, rightGazeOffset) + } } -// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate - extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate { nonisolated func captureOutput( _ output: AVCaptureOutput, @@ -270,13 +608,27 @@ extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate { return } + let size = CGSize( + width: CVPixelBufferGetWidth(pixelBuffer), + height: CVPixelBufferGetHeight(pixelBuffer) + ) + Task { @MainActor in - self.processFaceObservations(request.results as? [VNFaceObservation]) + self.processFaceObservations( + request.results as? [VNFaceObservation], + imageSize: size + ) } } + // Use revision 3 which includes more detailed landmarks including pupils request.revision = VNDetectFaceLandmarksRequestRevision3 + // Enable constellation points which may help with pose estimation + if #available(macOS 14.0, *) { + request.constellation = .constellation76Points + } + let imageRequestHandler = VNImageRequestHandler( cvPixelBuffer: pixelBuffer, orientation: .leftMirrored, diff --git a/Gaze/Services/PupilDetector.swift b/Gaze/Services/PupilDetector.swift new file mode 100644 index 0000000..52c66a3 --- /dev/null +++ b/Gaze/Services/PupilDetector.swift @@ -0,0 +1,266 @@ +// +// PupilDetector.swift +// Gaze +// +// Created by Mike Freno on 1/15/26. +// +// Pixel-based pupil detection translated from Python GazeTracking library +// Original: https://github.com/antoinelame/GazeTracking +// + +import CoreImage +import Vision +import Accelerate + +struct PupilPosition { + let x: CGFloat + let y: CGFloat +} + +struct EyeRegion { + let frame: CGRect // Bounding box of the eye in image coordinates + let center: CGPoint // Center point of the eye region +} + +class PupilDetector { + + /// Detects pupil position within an isolated eye region using pixel-based analysis + /// - Parameters: + /// - pixelBuffer: The camera frame pixel buffer + /// - eyeLandmarks: Vision eye landmarks (6 points around iris) + /// - faceBoundingBox: Face bounding box from Vision + /// - imageSize: Size of the camera frame + /// - Returns: Pupil position relative to eye region, or nil if detection fails + static func detectPupil( + in pixelBuffer: CVPixelBuffer, + eyeLandmarks: VNFaceLandmarkRegion2D, + faceBoundingBox: CGRect, + imageSize: CGSize + ) -> (pupilPosition: PupilPosition, eyeRegion: EyeRegion)? { + + // Step 1: Convert Vision landmarks to pixel coordinates + let eyePoints = landmarksToPixelCoordinates( + landmarks: eyeLandmarks, + faceBoundingBox: faceBoundingBox, + imageSize: imageSize + ) + + guard eyePoints.count >= 6 else { return nil } + + // Step 2: Create eye region bounding box + guard let eyeRegion = createEyeRegion(from: eyePoints, imageSize: imageSize) else { + return nil + } + + // Step 3: Extract and process eye region from pixel buffer + guard let eyeImage = extractEyeRegion( + from: pixelBuffer, + region: eyeRegion.frame, + mask: eyePoints + ) else { + return nil + } + + // Step 4: Process image to isolate pupil (bilateral filter + threshold) + guard let processedImage = processEyeImage(eyeImage) else { + return nil + } + + // Step 5: Find pupil using contour detection + guard let pupilPosition = findPupilCentroid(in: processedImage) else { + return nil + } + + return (pupilPosition, eyeRegion) + } + + // MARK: - Step 1: Convert Landmarks to Pixel Coordinates + + private static func landmarksToPixelCoordinates( + landmarks: VNFaceLandmarkRegion2D, + faceBoundingBox: CGRect, + imageSize: CGSize + ) -> [CGPoint] { + return landmarks.normalizedPoints.map { point in + // Vision coordinates are normalized to face bounding box + let imageX = (faceBoundingBox.origin.x + point.x * faceBoundingBox.width) * imageSize.width + let imageY = (faceBoundingBox.origin.y + point.y * faceBoundingBox.height) * imageSize.height + return CGPoint(x: imageX, y: imageY) + } + } + + // MARK: - Step 2: Create Eye Region + + private static func createEyeRegion(from points: [CGPoint], imageSize: CGSize) -> EyeRegion? { + guard !points.isEmpty else { return nil } + + let margin: CGFloat = 5 + let minX = points.map { $0.x }.min()! - margin + let maxX = points.map { $0.x }.max()! + margin + let minY = points.map { $0.y }.min()! - margin + let maxY = points.map { $0.y }.max()! + margin + + // Clamp to image bounds + let clampedMinX = max(0, minX) + let clampedMaxX = min(imageSize.width, maxX) + let clampedMinY = max(0, minY) + let clampedMaxY = min(imageSize.height, maxY) + + let frame = CGRect( + x: clampedMinX, + y: clampedMinY, + width: clampedMaxX - clampedMinX, + height: clampedMaxY - clampedMinY + ) + + let center = CGPoint( + x: frame.width / 2, + y: frame.height / 2 + ) + + return EyeRegion(frame: frame, center: center) + } + + // MARK: - Step 3: Extract Eye Region + + private static func extractEyeRegion( + from pixelBuffer: CVPixelBuffer, + region: CGRect, + mask: [CGPoint] + ) -> CIImage? { + + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + + // Convert to grayscale + let grayscaleImage = ciImage.applyingFilter("CIPhotoEffectNoir") + + // Crop to eye region + let croppedImage = grayscaleImage.cropped(to: region) + + return croppedImage + } + + // MARK: - Step 4: Process Eye Image + + private static func processEyeImage(_ image: CIImage) -> CIImage? { + // Apply bilateral filter (preserves edges while smoothing) + // CIBilateralFilter approximation: use CIMedianFilter + morphology + var processed = image + + // 1. Median filter (reduces noise while preserving edges) + processed = processed.applyingFilter("CIMedianFilter") + + // 2. Morphological erosion (makes dark regions larger - approximates cv2.erode) + // Use CIMorphologyMinimum with small radius + processed = processed.applyingFilter("CIMorphologyMinimum", parameters: [ + kCIInputRadiusKey: 2.0 + ]) + + // 3. Threshold to binary (black/white) + // Use CIColorControls to increase contrast, then threshold + processed = processed.applyingFilter("CIColorControls", parameters: [ + kCIInputContrastKey: 2.0, + kCIInputBrightnessKey: -0.3 + ]) + + // Apply color threshold to make it binary + processed = processed.applyingFilter("CIColorThreshold", parameters: [ + "inputThreshold": 0.5 + ]) + + return processed + } + + // MARK: - Step 5: Find Pupil Centroid + + private static func findPupilCentroid(in image: CIImage) -> PupilPosition? { + let context = CIContext() + + // Convert CIImage to CGImage for contour detection + guard let cgImage = context.createCGImage(image, from: image.extent) else { + return nil + } + + // Convert to vImage buffer for processing + guard let (width, height, data) = cgImageToGrayscaleData(cgImage) else { + return nil + } + + // Find connected components (contours) + guard let (centroidX, centroidY) = findLargestDarkRegionCentroid( + data: data, + width: width, + height: height + ) else { + return nil + } + + return PupilPosition(x: CGFloat(centroidX), y: CGFloat(centroidY)) + } + + // MARK: - Helper: Convert CGImage to Grayscale Data + + private static func cgImageToGrayscaleData(_ cgImage: CGImage) -> (width: Int, height: Int, data: [UInt8])? { + let width = cgImage.width + let height = cgImage.height + + var data = [UInt8](repeating: 0, count: width * height) + + guard let context = CGContext( + data: &data, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { + return nil + } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + return (width, height, data) + } + + // MARK: - Helper: Find Centroid of Largest Dark Region + + private static func findLargestDarkRegionCentroid( + data: [UInt8], + width: Int, + height: Int + ) -> (x: Double, y: Double)? { + + // Calculate image moments to find centroid + // m00 = sum of all pixels (area) + // m10 = sum of (x * pixel_value) + // m01 = sum of (y * pixel_value) + // centroid_x = m10 / m00 + // centroid_y = m01 / m00 + + var m00: Double = 0 + var m10: Double = 0 + var m01: Double = 0 + + for y in 0.. 128 { // Only count dark pixels + let weight = Double(pixelValue) + m00 += weight + m10 += Double(x) * weight + m01 += Double(y) * weight + } + } + } + + guard m00 > 0 else { return nil } + + let centroidX = m10 / m00 + let centroidY = m01 / m00 + + return (centroidX, centroidY) + } +} diff --git a/Gaze/Services/ServiceContainer.swift b/Gaze/Services/ServiceContainer.swift new file mode 100644 index 0000000..dc2397e --- /dev/null +++ b/Gaze/Services/ServiceContainer.swift @@ -0,0 +1,110 @@ +// +// ServiceContainer.swift +// Gaze +// +// Dependency injection container for managing service instances. +// + +import Foundation + +/// A simple dependency injection container for managing service instances. +/// Supports both production and test configurations. +@MainActor +final class ServiceContainer { + + /// Shared instance for production use + static let shared = ServiceContainer() + + /// The settings manager instance + private(set) var settingsManager: any SettingsProviding + + /// The enforce mode service instance + private(set) var enforceModeService: EnforceModeService + + /// The timer engine instance (created lazily) + private var _timerEngine: TimerEngine? + + /// The fullscreen detection service + private(set) var fullscreenService: FullscreenDetectionService? + + /// The idle monitoring service + private(set) var idleService: IdleMonitoringService? + + /// The usage tracking service + private(set) var usageTrackingService: UsageTrackingService? + + /// Whether this container is configured for testing + let isTestEnvironment: Bool + + /// Creates a production container with real services + private init() { + self.isTestEnvironment = false + self.settingsManager = SettingsManager.shared + self.enforceModeService = EnforceModeService.shared + } + + /// Creates a test container with injectable dependencies + /// - Parameters: + /// - settingsManager: The settings manager to use (defaults to MockSettingsManager in tests) + /// - enforceModeService: The enforce mode service to use + init( + settingsManager: any SettingsProviding, + enforceModeService: EnforceModeService? = nil + ) { + self.isTestEnvironment = true + self.settingsManager = settingsManager + self.enforceModeService = enforceModeService ?? EnforceModeService.shared + } + + /// Gets or creates the timer engine + var timerEngine: TimerEngine { + if let engine = _timerEngine { + return engine + } + let engine = TimerEngine( + settingsManager: settingsManager, + enforceModeService: enforceModeService + ) + _timerEngine = engine + return engine + } + + /// Sets up smart mode services + func setupSmartModeServices() { + let settings = settingsManager.settings + + fullscreenService = FullscreenDetectionService() + idleService = IdleMonitoringService( + idleThresholdMinutes: settings.smartMode.idleThresholdMinutes + ) + usageTrackingService = UsageTrackingService( + resetThresholdMinutes: settings.smartMode.usageResetAfterMinutes + ) + + // Connect idle service to usage tracking + if let idleService = idleService { + usageTrackingService?.setupIdleMonitoring(idleService) + } + + // Connect services to timer engine + timerEngine.setupSmartMode( + fullscreenService: fullscreenService, + idleService: idleService + ) + } + + /// Resets the container for testing purposes + func reset() { + _timerEngine?.stop() + _timerEngine = nil + fullscreenService = nil + idleService = nil + usageTrackingService = nil + } + + /// Creates a new container configured for testing + static func forTesting(settings: AppSettings = .defaults) -> ServiceContainer { + // We need to create this at runtime in tests using MockSettingsManager + fatalError("Use init(settingsManager:) directly in tests") + } +} diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index b22ccee..f38c275 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -14,7 +14,7 @@ class TimerEngine: ObservableObject { @Published var activeReminder: ReminderEvent? private var timerSubscription: AnyCancellable? - private let settingsManager: SettingsManager + private let settingsProvider: any SettingsProviding private var sleepStartTime: Date? // For enforce mode integration @@ -25,9 +25,9 @@ class TimerEngine: ObservableObject { private var idleService: IdleMonitoringService? private var cancellables = Set() - init(settingsManager: SettingsManager) { - self.settingsManager = settingsManager - self.enforceModeService = EnforceModeService.shared + init(settingsManager: any SettingsProviding, enforceModeService: EnforceModeService? = nil) { + self.settingsProvider = settingsManager + self.enforceModeService = enforceModeService ?? EnforceModeService.shared Task { @MainActor in self.enforceModeService?.setTimerEngine(self) @@ -61,7 +61,7 @@ class TimerEngine: ObservableObject { } private func handleFullscreenChange(isFullscreen: Bool) { - guard settingsManager.settings.smartMode.autoPauseOnFullscreen else { return } + guard settingsProvider.settings.smartMode.autoPauseOnFullscreen else { return } if isFullscreen { pauseAllTimers(reason: .fullscreen) @@ -73,7 +73,7 @@ class TimerEngine: ObservableObject { } private func handleIdleChange(isIdle: Bool) { - guard settingsManager.settings.smartMode.autoPauseOnIdle else { return } + guard settingsProvider.settings.smartMode.autoPauseOnIdle else { return } if isIdle { pauseAllTimers(reason: .idle) @@ -114,7 +114,7 @@ class TimerEngine: ObservableObject { // Add built-in timers for timerType in TimerType.allCases { - let config = settingsManager.timerConfiguration(for: timerType) + let config = settingsProvider.timerConfiguration(for: timerType) if config.enabled { let identifier = TimerIdentifier.builtIn(timerType) newStates[identifier] = TimerState( @@ -127,7 +127,7 @@ class TimerEngine: ObservableObject { } // Add user timers - for userTimer in settingsManager.settings.userTimers where userTimer.enabled { + for userTimer in settingsProvider.settings.userTimers where userTimer.enabled { let identifier = TimerIdentifier.user(id: userTimer.id) newStates[identifier] = TimerState( identifier: identifier, @@ -159,7 +159,7 @@ class TimerEngine: ObservableObject { // Update built-in timers for timerType in TimerType.allCases { - let config = settingsManager.timerConfiguration(for: timerType) + let config = settingsProvider.timerConfiguration(for: timerType) let identifier = TimerIdentifier.builtIn(timerType) if config.enabled { @@ -191,7 +191,7 @@ class TimerEngine: ObservableObject { } // Update user timers - for userTimer in settingsManager.settings.userTimers { + for userTimer in settingsProvider.settings.userTimers { let identifier = TimerIdentifier.user(id: userTimer.id) let newIntervalSeconds = userTimer.intervalMinutes * 60 @@ -269,10 +269,10 @@ class TimerEngine: ObservableObject { let intervalSeconds: Int switch identifier { case .builtIn(let type): - let config = settingsManager.timerConfiguration(for: type) + let config = settingsProvider.timerConfiguration(for: type) intervalSeconds = config.intervalSeconds case .user(let id): - guard let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) else { return } + guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else { return } intervalSeconds = userTimer.intervalMinutes * 60 } @@ -335,14 +335,14 @@ class TimerEngine: ObservableObject { switch type { case .lookAway: activeReminder = .lookAwayTriggered( - countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds) + countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds) case .blink: activeReminder = .blinkTriggered case .posture: activeReminder = .postureTriggered } case .user(let id): - if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) { + if let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) { activeReminder = .userTimerTriggered(userTimer) } } diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index 6cdf2b4..dafab04 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -7,6 +7,108 @@ import SwiftUI +@MainActor +final class SettingsWindowPresenter { + static let shared = SettingsWindowPresenter() + + private weak var windowController: NSWindowController? + private var closeObserver: NSObjectProtocol? + + func show(settingsManager: SettingsManager, initialTab: Int = 0) { + if focusExistingWindow(tab: initialTab) { + return + } + createWindow(settingsManager: settingsManager, initialTab: initialTab) + } + + func focus(tab: Int) { + _ = focusExistingWindow(tab: tab) + } + + func close() { + windowController?.close() + windowController = nil + removeCloseObserver() + } + + @discardableResult + private func focusExistingWindow(tab: Int?) -> Bool { + guard let window = windowController?.window else { + windowController = nil + return false + } + + if let tab { + NotificationCenter.default.post( + name: Notification.Name("SwitchToSettingsTab"), + object: tab + ) + } + + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return true + } + + private func createWindow(settingsManager: SettingsManager, initialTab: Int) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.identifier = WindowIdentifiers.settings + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.toolbarStyle = .unified + window.toolbar = NSToolbar() + window.center() + window.setFrameAutosaveName("SettingsWindow") + window.isReleasedWhenClosed = false + + let contentView = SettingsWindowView( + settingsManager: settingsManager, + initialTab: initialTab + ) + window.contentView = NSHostingView(rootView: contentView) + + let controller = NSWindowController(window: window) + controller.showWindow(nil) + + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + windowController = controller + + removeCloseObserver() + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.windowController = nil + self?.removeCloseObserver() + } + } + } + + @MainActor + private func removeCloseObserver() { + if let closeObserver { + NotificationCenter.default.removeObserver(closeObserver) + self.closeObserver = nil + } + } + + deinit { + Task { @MainActor in + removeCloseObserver() + } + } +} + struct SettingsWindowView: View { @ObservedObject var settingsManager: SettingsManager @State private var selectedSection: SettingsSection diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index 32e92cc..ba8f93f 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -13,92 +13,98 @@ struct EnforceModeSetupView: View { @ObservedObject var cameraService = CameraAccessService.shared @ObservedObject var eyeTrackingService = EyeTrackingService.shared @ObservedObject var enforceModeService = EnforceModeService.shared + @ObservedObject var trackingConstants = EyeTrackingConstants.shared @State private var isProcessingToggle = false @State private var isTestModeActive = false @State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer? @State private var showDebugView = false @State private var isViewActive = false + @State private var showAdvancedSettings = false var body: some View { VStack(spacing: 0) { - VStack(spacing: 16) { - Image(systemName: "video.fill") - .font(.system(size: 60)) - .foregroundColor(.accentColor) - Text("Enforce Mode") - .font(.system(size: 28, weight: .bold)) - } - .padding(.top, 20) - .padding(.bottom, 30) + ScrollView { + VStack(spacing: 16) { + Image(systemName: "video.fill") + .font(.system(size: 60)) + .foregroundColor(.accentColor) + Text("Enforce Mode") + .font(.system(size: 28, weight: .bold)) + } + .padding(.top, 20) + .padding(.bottom, 30) - Spacer() + Spacer() - VStack(spacing: 30) { - Text("Use your camera to ensure you take breaks") - .font(.title3) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + VStack(spacing: 30) { + Text("Use your camera to ensure you take breaks") + .font(.title3) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) - VStack(spacing: 20) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Enable Enforce Mode") - .font(.headline) - Text("Camera activates 3 seconds before lookaway reminders") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Toggle( - "", - isOn: Binding( - get: { - settingsManager.settings.enforcementMode - }, - set: { newValue in - print("๐ŸŽ›๏ธ Toggle changed to: \(newValue)") - guard !isProcessingToggle else { - print("โš ๏ธ Already processing toggle") - return + VStack(spacing: 20) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Enable Enforce Mode") + .font(.headline) + Text("Camera activates 3 seconds before lookaway reminders") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Toggle( + "", + isOn: Binding( + get: { + settingsManager.settings.enforcementMode + }, + set: { newValue in + print("๐ŸŽ›๏ธ Toggle changed to: \(newValue)") + guard !isProcessingToggle else { + print("โš ๏ธ Already processing toggle") + return + } + settingsManager.settings.enforcementMode = newValue + handleEnforceModeToggle(enabled: newValue) } - settingsManager.settings.enforcementMode = newValue - handleEnforceModeToggle(enabled: newValue) - } + ) ) - ) - .labelsHidden() - .disabled(isProcessingToggle) - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + .labelsHidden() + .disabled(isProcessingToggle) + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - cameraStatusView + cameraStatusView - if enforceModeService.isEnforceModeEnabled { - testModeButton - } - - if isTestModeActive && enforceModeService.isCameraActive { - testModePreviewView - } else { - if enforceModeService.isCameraActive && !isTestModeActive { - eyeTrackingStatusView - #if DEBUG - if showDebugView { - debugEyeTrackingView - } - #endif - } else if enforceModeService.isEnforceModeEnabled { - cameraPendingView + if enforceModeService.isEnforceModeEnabled { + testModeButton } - privacyInfoView + if isTestModeActive && enforceModeService.isCameraActive { + testModePreviewView + trackingConstantsView + } else { + if enforceModeService.isCameraActive && !isTestModeActive { + trackingConstantsView + eyeTrackingStatusView + #if DEBUG + if showDebugView { + debugEyeTrackingView + } + #endif + } else if enforceModeService.isEnforceModeEnabled { + cameraPendingView + } + + privacyInfoView + } } } - } - Spacer() + Spacer() + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() @@ -163,35 +169,35 @@ struct EnforceModeSetupView: View { } } - VStack(alignment: .leading, spacing: 12) { - Text("Live Tracking Status") - .font(.headline) + /*VStack(alignment: .leading, spacing: 12) {*/ + /*Text("Live Tracking Status")*/ + /*.font(.headline)*/ - HStack(spacing: 20) { - statusIndicator( - title: "Face Detected", - isActive: eyeTrackingService.faceDetected, - icon: "person.fill" - ) + /*HStack(spacing: 20) {*/ + /*statusIndicator(*/ + /*title: "Face Detected",*/ + /*isActive: eyeTrackingService.faceDetected,*/ + /*icon: "person.fill"*/ + /*)*/ - statusIndicator( - title: "Looking Away", - isActive: !eyeTrackingService.userLookingAtScreen, - icon: "arrow.turn.up.right" - ) - } + /*statusIndicator(*/ + /*title: "Looking Away",*/ + /*isActive: !eyeTrackingService.userLookingAtScreen,*/ + /*icon: "arrow.turn.up.right"*/ + /*)*/ + /*}*/ - Text( - lookingAway - ? "โœ“ Break compliance detected" : "โš ๏ธ Please look away from screen" - ) - .font(.caption) - .foregroundColor(lookingAway ? .green : .orange) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 4) - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + /*Text(*/ + /*lookingAway*/ + /*? "โœ“ Break compliance detected" : "โš ๏ธ Please look away from screen"*/ + /*)*/ + /*.font(.caption)*/ + /*.foregroundColor(lookingAway ? .green : .orange)*/ + /*.frame(maxWidth: .infinity, alignment: .center)*/ + /*.padding(.top, 4)*/ + /*}*/ + /*.padding()*/ + /*.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))*/ } } } @@ -357,6 +363,269 @@ struct EnforceModeSetupView: View { } } + private var trackingConstantsView: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Tracking Sensitivity") + .font(.headline) + Spacer() + Button(action: { + eyeTrackingService.enableDebugLogging.toggle() + }) { + Image(systemName: eyeTrackingService.enableDebugLogging ? "ant.circle.fill" : "ant.circle") + .foregroundColor(eyeTrackingService.enableDebugLogging ? .orange : .secondary) + } + .buttonStyle(.plain) + .help("Toggle console debug logging") + + Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") { + withAnimation { + showAdvancedSettings.toggle() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + // Debug info always visible when tracking + VStack(alignment: .leading, spacing: 8) { + Text("Live Values:") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + if let leftRatio = eyeTrackingService.debugLeftPupilRatio, + let rightRatio = eyeTrackingService.debugRightPupilRatio { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Left Pupil: \(String(format: "%.3f", leftRatio))") + .font(.caption2) + .foregroundColor( + !trackingConstants.minPupilEnabled && !trackingConstants.maxPupilEnabled ? .secondary : + (leftRatio < trackingConstants.minPupilRatio || leftRatio > trackingConstants.maxPupilRatio) ? .orange : .green + ) + Text("Right Pupil: \(String(format: "%.3f", rightRatio))") + .font(.caption2) + .foregroundColor( + !trackingConstants.minPupilEnabled && !trackingConstants.maxPupilEnabled ? .secondary : + (rightRatio < trackingConstants.minPupilRatio || rightRatio > trackingConstants.maxPupilRatio) ? .orange : .green + ) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("Range: \(String(format: "%.2f", trackingConstants.minPupilRatio)) - \(String(format: "%.2f", trackingConstants.maxPupilRatio))") + .font(.caption2) + .foregroundColor(.secondary) + let bothEyesOut = (leftRatio < trackingConstants.minPupilRatio || leftRatio > trackingConstants.maxPupilRatio) && + (rightRatio < trackingConstants.minPupilRatio || rightRatio > trackingConstants.maxPupilRatio) + Text(bothEyesOut ? "Both Out โš ๏ธ" : "In Range โœ“") + .font(.caption2) + .foregroundColor(bothEyesOut ? .orange : .green) + } + } + } else { + Text("Pupil data unavailable") + .font(.caption2) + .foregroundColor(.secondary) + } + + if let yaw = eyeTrackingService.debugYaw, + let pitch = eyeTrackingService.debugPitch { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Yaw: \(String(format: "%.3f", yaw))") + .font(.caption2) + .foregroundColor( + !trackingConstants.yawEnabled ? .secondary : + abs(yaw) > trackingConstants.yawThreshold ? .orange : .green + ) + Text("Pitch: \(String(format: "%.3f", pitch))") + .font(.caption2) + .foregroundColor( + !trackingConstants.pitchUpEnabled && !trackingConstants.pitchDownEnabled ? .secondary : + (pitch > trackingConstants.pitchUpThreshold || pitch < trackingConstants.pitchDownThreshold) ? .orange : .green + ) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("Yaw Max: \(String(format: "%.2f", trackingConstants.yawThreshold))") + .font(.caption2) + .foregroundColor(.secondary) + Text("Pitch: \(String(format: "%.2f", trackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", trackingConstants.pitchUpThreshold))") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + .padding(.top, 4) + + if showAdvancedSettings { + VStack(spacing: 16) { + // Yaw Threshold + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle("", isOn: $trackingConstants.yawEnabled) + .labelsHidden() + Text("Yaw Threshold (Head Turn)") + .foregroundColor( + trackingConstants.yawEnabled ? .primary : .secondary) + Spacer() + Text(String(format: "%.2f rad", trackingConstants.yawThreshold)) + .foregroundColor(.secondary) + .font(.caption) + } + Slider(value: $trackingConstants.yawThreshold, in: 0.1...0.8, step: 0.05) + .disabled(!trackingConstants.yawEnabled) + Text("Lower = more sensitive to head turning") + .font(.caption2) + .foregroundColor(.secondary) + } + + Divider() + + // Pitch Up Threshold + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle("", isOn: $trackingConstants.pitchUpEnabled) + .labelsHidden() + Text("Pitch Up Threshold (Looking Up)") + .foregroundColor( + trackingConstants.pitchUpEnabled ? .primary : .secondary) + Spacer() + Text(String(format: "%.2f rad", trackingConstants.pitchUpThreshold)) + .foregroundColor(.secondary) + .font(.caption) + } + Slider( + value: $trackingConstants.pitchUpThreshold, in: -0.2...0.5, step: 0.05 + ) + .disabled(!trackingConstants.pitchUpEnabled) + Text("Lower = more sensitive to looking up") + .font(.caption2) + .foregroundColor(.secondary) + } + + Divider() + + // Pitch Down Threshold + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle("", isOn: $trackingConstants.pitchDownEnabled) + .labelsHidden() + Text("Pitch Down Threshold (Looking Down)") + .foregroundColor( + trackingConstants.pitchDownEnabled ? .primary : .secondary) + Spacer() + Text(String(format: "%.2f rad", trackingConstants.pitchDownThreshold)) + .foregroundColor(.secondary) + .font(.caption) + } + Slider( + value: $trackingConstants.pitchDownThreshold, in: -0.8...0.0, step: 0.05 + ) + .disabled(!trackingConstants.pitchDownEnabled) + Text("Higher = more sensitive to looking down") + .font(.caption2) + .foregroundColor(.secondary) + } + + Divider() + + // Min Pupil Ratio + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle("", isOn: $trackingConstants.minPupilEnabled) + .labelsHidden() + Text("Min Pupil Ratio (Looking Right)") + .foregroundColor( + trackingConstants.minPupilEnabled ? .primary : .secondary) + Spacer() + Text(String(format: "%.2f", trackingConstants.minPupilRatio)) + .foregroundColor(.secondary) + .font(.caption) + } + Slider(value: $trackingConstants.minPupilRatio, in: 0.2...0.5, step: 0.01) + .disabled(!trackingConstants.minPupilEnabled) + Text("Higher = more sensitive to looking right") + .font(.caption2) + .foregroundColor(.secondary) + } + + Divider() + + // Max Pupil Ratio + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle("", isOn: $trackingConstants.maxPupilEnabled) + .labelsHidden() + Text("Max Pupil Ratio (Looking Left)") + .foregroundColor( + trackingConstants.maxPupilEnabled ? .primary : .secondary) + Spacer() + Text(String(format: "%.2f", trackingConstants.maxPupilRatio)) + .foregroundColor(.secondary) + .font(.caption) + } + Slider(value: $trackingConstants.maxPupilRatio, in: 0.5...0.8, step: 0.01) + .disabled(!trackingConstants.maxPupilEnabled) + Text("Lower = more sensitive to looking left") + .font(.caption2) + .foregroundColor(.secondary) + } + + Divider() + + // Eye Closed Threshold + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle("", isOn: $trackingConstants.eyeClosedEnabled) + .labelsHidden() + Text("Eye Closed Threshold") + .foregroundColor( + trackingConstants.eyeClosedEnabled ? .primary : .secondary) + Spacer() + Text(String(format: "%.3f", trackingConstants.eyeClosedThreshold)) + .foregroundColor(.secondary) + .font(.caption) + } + Slider( + value: Binding( + get: { Double(trackingConstants.eyeClosedThreshold) }, + set: { trackingConstants.eyeClosedThreshold = CGFloat($0) } + ), in: 0.01...0.1, step: 0.005 + ) + .disabled(!trackingConstants.eyeClosedEnabled) + Text("Lower = more sensitive to eye closure") + .font(.caption2) + .foregroundColor(.secondary) + } + + // Reset button + Button(action: { + trackingConstants.resetToDefaults() + }) { + HStack { + Image(systemName: "arrow.counterclockwise") + Text("Reset to Defaults") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .padding(.top, 8) + } + .padding(.top, 8) + } + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + private var debugEyeTrackingView: some View { VStack(alignment: .leading, spacing: 12) { Text("Debug Eye Tracking Data") diff --git a/GazeTests/Mocks/MockSettingsManager.swift b/GazeTests/Mocks/MockSettingsManager.swift new file mode 100644 index 0000000..af42616 --- /dev/null +++ b/GazeTests/Mocks/MockSettingsManager.swift @@ -0,0 +1,76 @@ +// +// MockSettingsManager.swift +// GazeTests +// +// A mock implementation of SettingsProviding for isolated unit testing. +// + +import Combine +import Foundation +@testable import Gaze + +/// A mock implementation of SettingsProviding that doesn't use UserDefaults. +/// This allows tests to run in complete isolation without affecting +/// the shared singleton or persisting data. +@MainActor +final class MockSettingsManager: ObservableObject, SettingsProviding { + @Published var settings: AppSettings + + var settingsPublisher: Published.Publisher { + $settings + } + + private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ + .lookAway: \.lookAwayTimer, + .blink: \.blinkTimer, + .posture: \.postureTimer, + ] + + /// Track method calls for verification in tests + var saveCallCount = 0 + var loadCallCount = 0 + var resetToDefaultsCallCount = 0 + + init(settings: AppSettings = .defaults) { + self.settings = settings + } + + func timerConfiguration(for type: TimerType) -> TimerConfiguration { + guard let keyPath = timerConfigKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + return settings[keyPath: keyPath] + } + + func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { + guard let keyPath = timerConfigKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + settings[keyPath: keyPath] = configuration + } + + func allTimerConfigurations() -> [TimerType: TimerConfiguration] { + var configs: [TimerType: TimerConfiguration] = [:] + for (type, keyPath) in timerConfigKeyPaths { + configs[type] = settings[keyPath: keyPath] + } + return configs + } + + func save() { + saveCallCount += 1 + } + + func saveImmediately() { + saveCallCount += 1 + } + + func load() { + loadCallCount += 1 + } + + func resetToDefaults() { + resetToDefaultsCallCount += 1 + settings = .defaults + } +}