feat: continued

This commit is contained in:
Michael Freno
2026-01-14 17:06:12 -05:00
parent 64f41f8bef
commit d96807ce79
5 changed files with 174 additions and 15 deletions

View File

@@ -22,6 +22,9 @@ class EnforceModeService: ObservableObject {
private var timerEngine: TimerEngine? private var timerEngine: TimerEngine?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var faceDetectionTimer: Timer?
private var lastFaceDetectionTime: Date = Date.distantPast
private let faceDetectionTimeout: TimeInterval = 5.0 // 5 seconds to consider person lost
private init() { private init() {
self.settingsManager = SettingsManager.shared self.settingsManager = SettingsManager.shared
@@ -36,6 +39,13 @@ class EnforceModeService: ObservableObject {
self?.handleGazeChange(lookingAtScreen: lookingAtScreen) self?.handleGazeChange(lookingAtScreen: lookingAtScreen)
} }
.store(in: &cancellables) .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() { private func initializeEnforceModeState() {
@@ -126,6 +136,10 @@ class EnforceModeService: ObservableObject {
eyeTrackingService.stopEyeTracking() eyeTrackingService.stopEyeTracking()
isCameraActive = false isCameraActive = false
userCompliedWithBreak = false userCompliedWithBreak = false
// Invalidate the face detection timer when stopping camera
faceDetectionTimer?.invalidate()
faceDetectionTimer = nil
} }
func checkUserCompliance() { func checkUserCompliance() {
@@ -144,9 +158,43 @@ class EnforceModeService: ObservableObject {
checkUserCompliance() 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() { 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
if isCameraActive {
stopCamera() stopCamera()
} }
}
func startTestMode() async { func startTestMode() async {
guard isEnforceModeEnabled else { return } guard isEnforceModeEnabled else { return }

View File

@@ -114,31 +114,53 @@ class EyeTrackingService: NSObject, ObservableObject {
} }
private func processFaceObservations(_ observations: [VNFaceObservation]?) { private func processFaceObservations(_ observations: [VNFaceObservation]?) {
print("🔍 Processing face observations...")
guard let observations = observations, !observations.isEmpty else { guard let observations = observations, !observations.isEmpty else {
print("❌ No faces detected")
faceDetected = false faceDetected = false
userLookingAtScreen = false userLookingAtScreen = false
return return
} }
faceDetected = true faceDetected = true
let face = observations.first!
guard let face = observations.first, print("✅ Face detected. Bounding box: \(face.boundingBox)")
let landmarks = face.landmarks else {
guard let landmarks = face.landmarks else {
print("❌ No face landmarks detected")
return return
} }
// Log eye landmarks
if let leftEye = landmarks.leftEye, if let leftEye = landmarks.leftEye,
let rightEye = landmarks.rightEye { 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) let eyesClosed = detectEyesClosed(leftEye: leftEye, rightEye: rightEye)
self.isEyesClosed = eyesClosed self.isEyesClosed = eyesClosed
print("👁️ Eyes closed: \(eyesClosed)")
} }
// Log gaze detection
let lookingAway = detectLookingAway(face: face, landmarks: landmarks) let lookingAway = detectLookingAway(face: face, landmarks: landmarks)
userLookingAtScreen = !lookingAway 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 { private func detectEyesClosed(leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D) -> Bool {
guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else {
print("⚠️ Eye landmarks insufficient for eye closure detection")
return false return false
} }
@@ -147,18 +169,26 @@ class EyeTrackingService: NSObject, ObservableObject {
let closedThreshold: CGFloat = 0.02 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 { private func calculateEyeHeight(_ eye: VNFaceLandmarkRegion2D) -> CGFloat {
let points = eye.normalizedPoints let points = eye.normalizedPoints
print("📏 Eye points count: \(points.count)")
guard points.count >= 2 else { return 0 } guard points.count >= 2 else { return 0 }
let yValues = points.map { $0.y } let yValues = points.map { $0.y }
let maxY = yValues.max() ?? 0 let maxY = yValues.max() ?? 0
let minY = yValues.min() ?? 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 { private func detectLookingAway(face: VNFaceObservation, landmarks: VNFaceLandmarks2D) -> Bool {
@@ -170,6 +200,10 @@ class EyeTrackingService: NSObject, ObservableObject {
let isLookingAway = abs(yaw) > yawThreshold || abs(roll) > rollThreshold 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 return isLookingAway
} }
} }

View File

@@ -84,7 +84,7 @@ struct SettingsWindowView: View {
} }
.tabViewStyle(.sidebarAdaptable) .tabViewStyle(.sidebarAdaptable)
} else { } else {
// Fallback for macOS 14 and earlier // Fallback for macOS 14 and earlier - use a consistent sidebar approach without collapse button
NavigationSplitView { NavigationSplitView {
List(SettingsSection.allCases, selection: $selectedSection) { section in List(SettingsSection.allCases, selection: $selectedSection) { section in
NavigationLink(value: section) { NavigationLink(value: section) {
@@ -96,6 +96,8 @@ struct SettingsWindowView: View {
} detail: { } detail: {
detailView(for: selectedSection) detailView(for: selectedSection)
} }
// Disable the ability to collapse the sidebar by explicitly setting a fixed width
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 300)
} }
Divider() Divider()

View File

@@ -17,6 +17,8 @@ struct EnforceModeSetupView: View {
@State private var isProcessingToggle = false @State private var isProcessingToggle = false
@State private var isTestModeActive = false @State private var isTestModeActive = false
@State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer? @State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
@State private var showDebugView = false
@State private var isViewActive = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -71,6 +73,18 @@ struct EnforceModeSetupView: View {
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .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 cameraStatusView
if enforceModeService.isEnforceModeEnabled { if enforceModeService.isEnforceModeEnabled {
@@ -82,6 +96,11 @@ struct EnforceModeSetupView: View {
} else { } else {
if enforceModeService.isCameraActive && !isTestModeActive { if enforceModeService.isCameraActive && !isTestModeActive {
eyeTrackingStatusView eyeTrackingStatusView
#if DEBUG
if showDebugView {
debugEyeTrackingView
}
#endif
} else if enforceModeService.isEnforceModeEnabled { } else if enforceModeService.isEnforceModeEnabled {
cameraPendingView cameraPendingView
} }
@@ -96,6 +115,17 @@ struct EnforceModeSetupView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
.background(.clear) .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 { private var testModeButton: some View {
@@ -330,11 +360,47 @@ struct EnforceModeSetupView: View {
} else { } else {
print("🎛️ Disabling enforce mode...") print("🎛️ Disabling enforce mode...")
enforceModeService.disableEnforceMode() 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 { #Preview {
EnforceModeSetupView(settingsManager: SettingsManager.shared) EnforceModeSetupView(settingsManager: SettingsManager.shared)
} }

View File

@@ -52,8 +52,9 @@ struct SmartModeSetupView: View {
"", "",
isOn: Binding( isOn: Binding(
get: { settingsManager.settings.smartMode.autoPauseOnFullscreen }, get: { settingsManager.settings.smartMode.autoPauseOnFullscreen },
set: { set: { newValue in
settingsManager.settings.smartMode.autoPauseOnFullscreen = $0 print("🔧 Smart Mode - Auto-pause on fullscreen changed to: \(newValue)")
settingsManager.settings.smartMode.autoPauseOnFullscreen = newValue
} }
) )
) )
@@ -84,7 +85,10 @@ struct SmartModeSetupView: View {
"", "",
isOn: Binding( isOn: Binding(
get: { settingsManager.settings.smartMode.autoPauseOnIdle }, 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() .labelsHidden()
@@ -109,9 +113,10 @@ struct SmartModeSetupView: View {
Double( Double(
settingsManager.settings.smartMode.idleThresholdMinutes) settingsManager.settings.smartMode.idleThresholdMinutes)
}, },
set: { set: { newValue in
print("🔧 Smart Mode - Idle threshold changed to: \(Int(newValue))")
settingsManager.settings.smartMode.idleThresholdMinutes = settingsManager.settings.smartMode.idleThresholdMinutes =
Int($0) Int(newValue)
} }
), ),
in: 1...30, in: 1...30,
@@ -145,7 +150,10 @@ struct SmartModeSetupView: View {
"", "",
isOn: Binding( isOn: Binding(
get: { settingsManager.settings.smartMode.trackUsage }, 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() .labelsHidden()
@@ -171,9 +179,10 @@ struct SmartModeSetupView: View {
settingsManager.settings.smartMode settingsManager.settings.smartMode
.usageResetAfterMinutes) .usageResetAfterMinutes)
}, },
set: { set: { newValue in
print("🔧 Smart Mode - Usage reset after changed to: \(Int(newValue))")
settingsManager.settings.smartMode.usageResetAfterMinutes = settingsManager.settings.smartMode.usageResetAfterMinutes =
Int($0) Int(newValue)
} }
), ),
in: 15...240, in: 15...240,