From d96807ce79b31988ecc13097e6f79b567fa725ea Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 14 Jan 2026 17:06:12 -0500 Subject: [PATCH] feat: continued --- Gaze/Services/EnforceModeService.swift | 50 +++++++++++++- Gaze/Services/EyeTrackingService.swift | 42 ++++++++++-- .../Views/Containers/SettingsWindowView.swift | 6 +- Gaze/Views/Setup/EnforceModeSetupView.swift | 66 +++++++++++++++++++ Gaze/Views/Setup/SmartModeSetupView.swift | 25 ++++--- 5 files changed, 174 insertions(+), 15 deletions(-) diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift index 78e6453..a17ff2b 100644 --- a/Gaze/Services/EnforceModeService.swift +++ b/Gaze/Services/EnforceModeService.swift @@ -22,6 +22,9 @@ class EnforceModeService: ObservableObject { 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 init() { self.settingsManager = SettingsManager.shared @@ -36,6 +39,13 @@ class EnforceModeService: ObservableObject { self?.handleGazeChange(lookingAtScreen: lookingAtScreen) } .store(in: &cancellables) + + // Observe face detection changes to track person presence + eyeTrackingService.$faceDetected + .sink { [weak self] faceDetected in + self?.handleFaceDetectionChange(faceDetected: faceDetected) + } + .store(in: &cancellables) } private func initializeEnforceModeState() { @@ -126,6 +136,10 @@ class EnforceModeService: ObservableObject { eyeTrackingService.stopEyeTracking() isCameraActive = false userCompliedWithBreak = false + + // Invalidate the face detection timer when stopping camera + faceDetectionTimer?.invalidate() + faceDetectionTimer = nil } func checkUserCompliance() { @@ -144,8 +158,42 @@ class EnforceModeService: ObservableObject { checkUserCompliance() } + private func handleFaceDetectionChange(faceDetected: Bool) { + // Update the last face detection time + if faceDetected { + lastFaceDetectionTime = Date() + } + + // If we are in enforce mode and camera is active, start checking for person presence + if isEnforceModeEnabled && isCameraActive { + // Cancel any existing timer and restart it + faceDetectionTimer?.invalidate() + + // Create a new timer to check for extended periods without face detection + faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + guard let self = self else { return } + + // Dispatch to main actor to safely access main actor-isolated properties and methods + Task { @MainActor in + let timeSinceLastDetection = Date().timeIntervalSince(self.lastFaceDetectionTime) + + // If person has not been detected for too long, temporarily disable enforce mode + if timeSinceLastDetection > self.faceDetectionTimeout { + print("⏰ Person not detected for \(self.faceDetectionTimeout)s. Temporarily disabling enforce mode.") + self.isEnforceModeEnabled = false + self.stopCamera() + } + } + } + } + } + func handleReminderDismissed() { - stopCamera() + // 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 + if isCameraActive { + stopCamera() + } } func startTestMode() async { diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index e09d16c..d53f546 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -114,31 +114,53 @@ class EyeTrackingService: NSObject, ObservableObject { } private func processFaceObservations(_ observations: [VNFaceObservation]?) { + print("🔍 Processing face observations...") guard let observations = observations, !observations.isEmpty else { + print("❌ No faces detected") faceDetected = false userLookingAtScreen = false return } faceDetected = true + let face = observations.first! - guard let face = observations.first, - let landmarks = face.landmarks else { + print("✅ Face detected. Bounding box: \(face.boundingBox)") + + guard let landmarks = face.landmarks else { + print("❌ No face landmarks detected") return } + // Log eye landmarks if let leftEye = landmarks.leftEye, let rightEye = landmarks.rightEye { + print("👁️ Left eye landmarks: \(leftEye.pointCount) points") + print("👁️ Right eye landmarks: \(rightEye.pointCount) points") + + let leftEyeHeight = calculateEyeHeight(leftEye) + let rightEyeHeight = calculateEyeHeight(rightEye) + + print("👁️ Left eye height: \(leftEyeHeight)") + print("👁️ Right eye height: \(rightEyeHeight)") + let eyesClosed = detectEyesClosed(leftEye: leftEye, rightEye: rightEye) self.isEyesClosed = eyesClosed + print("👁️ Eyes closed: \(eyesClosed)") } + // Log gaze detection let lookingAway = detectLookingAway(face: face, landmarks: landmarks) userLookingAtScreen = !lookingAway + + print("📊 Gaze angle - Yaw: \(face.yaw?.doubleValue ?? 0.0), Roll: \(face.roll?.doubleValue ?? 0.0)") + print("🎯 Looking away: \(lookingAway)") + print("👀 User looking at screen: \(userLookingAtScreen)") } private func detectEyesClosed(leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D) -> Bool { guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { + print("⚠️ Eye landmarks insufficient for eye closure detection") return false } @@ -147,18 +169,26 @@ class EyeTrackingService: NSObject, ObservableObject { let closedThreshold: CGFloat = 0.02 - return leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold + let isClosed = leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold + + print("👁️ Eye closure detection - Left: \(leftEyeHeight) < \(closedThreshold) = \(leftEyeHeight < closedThreshold), Right: \(rightEyeHeight) < \(closedThreshold) = \(rightEyeHeight < closedThreshold)") + + return isClosed } private func calculateEyeHeight(_ eye: VNFaceLandmarkRegion2D) -> CGFloat { let points = eye.normalizedPoints + print("📏 Eye points count: \(points.count)") guard points.count >= 2 else { return 0 } let yValues = points.map { $0.y } let maxY = yValues.max() ?? 0 let minY = yValues.min() ?? 0 - return abs(maxY - minY) + let height = abs(maxY - minY) + print("📏 Eye height calculation: max(\(maxY)) - min(\(minY)) = \(height)") + + return height } private func detectLookingAway(face: VNFaceObservation, landmarks: VNFaceLandmarks2D) -> Bool { @@ -170,6 +200,10 @@ class EyeTrackingService: NSObject, ObservableObject { let isLookingAway = abs(yaw) > yawThreshold || abs(roll) > rollThreshold + print("📊 Gaze detection - Yaw: \(yaw), Roll: \(roll)") + print("📉 Thresholds - Yaw: \(yawThreshold), Roll: \(rollThreshold)") + print("🎯 Looking away result: \(isLookingAway)") + return isLookingAway } } diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index be52b0c..fba2922 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -84,7 +84,7 @@ struct SettingsWindowView: View { } .tabViewStyle(.sidebarAdaptable) } else { - // Fallback for macOS 14 and earlier + // Fallback for macOS 14 and earlier - use a consistent sidebar approach without collapse button NavigationSplitView { List(SettingsSection.allCases, selection: $selectedSection) { section in NavigationLink(value: section) { @@ -96,6 +96,8 @@ struct SettingsWindowView: View { } detail: { detailView(for: selectedSection) } + // Disable the ability to collapse the sidebar by explicitly setting a fixed width + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 300) } Divider() @@ -193,4 +195,4 @@ struct SettingsWindowView: View { #Preview { SettingsWindowView(settingsManager: SettingsManager.shared) -} +} \ No newline at end of file diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index 2aa8c15..f7af977 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -17,6 +17,8 @@ struct EnforceModeSetupView: View { @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 var body: some View { VStack(spacing: 0) { @@ -71,6 +73,18 @@ struct EnforceModeSetupView: View { .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + #if DEBUG + HStack { + Button("Debug Info") { + showDebugView.toggle() + } + .buttonStyle(.bordered) + Spacer() + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + #endif + cameraStatusView if enforceModeService.isEnforceModeEnabled { @@ -82,6 +96,11 @@ struct EnforceModeSetupView: View { } else { if enforceModeService.isCameraActive && !isTestModeActive { eyeTrackingStatusView + #if DEBUG + if showDebugView { + debugEyeTrackingView + } + #endif } else if enforceModeService.isEnforceModeEnabled { cameraPendingView } @@ -96,6 +115,17 @@ struct EnforceModeSetupView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .background(.clear) + .onAppear { + isViewActive = true + } + .onDisappear { + isViewActive = false + // If the view disappeared and camera is still active, stop it + if enforceModeService.isCameraActive { + print("👁️ EnforceModeSetupView disappeared, stopping camera preview") + enforceModeService.stopCamera() + } + } } private var testModeButton: some View { @@ -330,9 +360,45 @@ struct EnforceModeSetupView: View { } else { print("🎛️ Disabling enforce mode...") enforceModeService.disableEnforceMode() + // Clean up camera when disabling enforce mode + if enforceModeService.isCameraActive { + print("👁️ Cleaning up camera on enforce mode disable") + enforceModeService.stopCamera() + } } } } + + private var debugEyeTrackingView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Debug Eye Tracking Data") + .font(.headline) + .foregroundColor(.blue) + + VStack(alignment: .leading, spacing: 8) { + Text("Face Detected: \(eyeTrackingService.faceDetected ? "Yes" : "No")") + .font(.caption) + + Text("Looking at Screen: \(eyeTrackingService.userLookingAtScreen ? "Yes" : "No")") + .font(.caption) + + Text("Eyes Closed: \(eyeTrackingService.isEyesClosed ? "Yes" : "No")") + .font(.caption) + + if eyeTrackingService.faceDetected { + Text("Yaw: 0.0") + .font(.caption) + + Text("Roll: 0.0") + .font(.caption) + } + } + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } } #Preview { diff --git a/Gaze/Views/Setup/SmartModeSetupView.swift b/Gaze/Views/Setup/SmartModeSetupView.swift index 059bc04..d37229e 100644 --- a/Gaze/Views/Setup/SmartModeSetupView.swift +++ b/Gaze/Views/Setup/SmartModeSetupView.swift @@ -52,8 +52,9 @@ struct SmartModeSetupView: View { "", isOn: Binding( get: { settingsManager.settings.smartMode.autoPauseOnFullscreen }, - set: { - settingsManager.settings.smartMode.autoPauseOnFullscreen = $0 + set: { newValue in + print("🔧 Smart Mode - Auto-pause on fullscreen changed to: \(newValue)") + settingsManager.settings.smartMode.autoPauseOnFullscreen = newValue } ) ) @@ -84,7 +85,10 @@ struct SmartModeSetupView: View { "", isOn: Binding( get: { settingsManager.settings.smartMode.autoPauseOnIdle }, - set: { settingsManager.settings.smartMode.autoPauseOnIdle = $0 } + set: { newValue in + print("🔧 Smart Mode - Auto-pause on idle changed to: \(newValue)") + settingsManager.settings.smartMode.autoPauseOnIdle = newValue + } ) ) .labelsHidden() @@ -109,9 +113,10 @@ struct SmartModeSetupView: View { Double( settingsManager.settings.smartMode.idleThresholdMinutes) }, - set: { + set: { newValue in + print("🔧 Smart Mode - Idle threshold changed to: \(Int(newValue))") settingsManager.settings.smartMode.idleThresholdMinutes = - Int($0) + Int(newValue) } ), in: 1...30, @@ -145,7 +150,10 @@ struct SmartModeSetupView: View { "", isOn: Binding( get: { settingsManager.settings.smartMode.trackUsage }, - set: { settingsManager.settings.smartMode.trackUsage = $0 } + set: { newValue in + print("🔧 Smart Mode - Track usage changed to: \(newValue)") + settingsManager.settings.smartMode.trackUsage = newValue + } ) ) .labelsHidden() @@ -171,9 +179,10 @@ struct SmartModeSetupView: View { settingsManager.settings.smartMode .usageResetAfterMinutes) }, - set: { + set: { newValue in + print("🔧 Smart Mode - Usage reset after changed to: \(Int(newValue))") settingsManager.settings.smartMode.usageResetAfterMinutes = - Int($0) + Int(newValue) } ), in: 15...240,