feat: continued
This commit is contained in:
@@ -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,8 +158,42 @@ 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() {
|
||||||
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 {
|
func startTestMode() async {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,9 +360,45 @@ 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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user