feat: continued enforce mode implementation
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||||
|
|||||||
@@ -32,5 +32,7 @@
|
|||||||
<integer>86400</integer>
|
<integer>86400</integer>
|
||||||
<key>SUEnableInstallerLauncherService</key>
|
<key>SUEnableInstallerLauncherService</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Gaze needs camera access to detect when you look away from your screen during enforce mode. All processing happens on-device and no images are stored or transmitted.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -20,20 +20,28 @@ class CameraAccessService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func requestCameraAccess() async throws {
|
func requestCameraAccess() async throws {
|
||||||
|
print("🎥 Requesting camera access...")
|
||||||
|
|
||||||
guard #available(macOS 12.0, *) else {
|
guard #available(macOS 12.0, *) else {
|
||||||
|
print("⚠️ macOS version too old")
|
||||||
throw CameraAccessError.unsupportedOS
|
throw CameraAccessError.unsupportedOS
|
||||||
}
|
}
|
||||||
|
|
||||||
if isCameraAuthorized {
|
if isCameraAuthorized {
|
||||||
|
print("✓ Camera already authorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("🎥 Calling AVCaptureDevice.requestAccess...")
|
||||||
let status = await AVCaptureDevice.requestAccess(for: .video)
|
let status = await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
print("🎥 Permission result: \(status)")
|
||||||
|
|
||||||
if !status {
|
if !status {
|
||||||
throw CameraAccessError.accessDenied
|
throw CameraAccessError.accessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
checkCameraAuthorizationStatus()
|
checkCameraAuthorizationStatus()
|
||||||
|
print("✓ Camera access granted")
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkCameraAuthorizationStatus() {
|
func checkCameraAuthorizationStatus() {
|
||||||
@@ -59,6 +67,13 @@ class CameraAccessService: ObservableObject {
|
|||||||
cameraError = CameraAccessError.unknown
|
cameraError = CameraAccessError.unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New method to check if face detection is supported and available
|
||||||
|
func isFaceDetectionAvailable() -> Bool {
|
||||||
|
// On macOS, face detection requires specific Vision framework support
|
||||||
|
// For now we'll assume it's available if camera is authorized
|
||||||
|
return isCameraAuthorized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error Handling
|
// MARK: - Error Handling
|
||||||
|
|||||||
110
Gaze/Services/EnforceModeService.swift
Normal file
110
Gaze/Services/EnforceModeService.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
//
|
||||||
|
// EnforceModeService.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/13/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class EnforceModeService: ObservableObject {
|
||||||
|
static let shared = EnforceModeService()
|
||||||
|
|
||||||
|
@Published var isEnforceModeActive = false
|
||||||
|
@Published var userCompliedWithBreak = false
|
||||||
|
|
||||||
|
private var settingsManager: SettingsManager
|
||||||
|
private var eyeTrackingService: EyeTrackingService
|
||||||
|
private var timerEngine: TimerEngine?
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.settingsManager = SettingsManager.shared
|
||||||
|
self.eyeTrackingService = EyeTrackingService.shared
|
||||||
|
setupObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
eyeTrackingService.$userLookingAtScreen
|
||||||
|
.sink { [weak self] lookingAtScreen in
|
||||||
|
self?.handleGazeChange(lookingAtScreen: lookingAtScreen)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableEnforceMode() async {
|
||||||
|
print("🔒 enableEnforceMode called")
|
||||||
|
guard !isEnforceModeActive else {
|
||||||
|
print("⚠️ Enforce mode already active")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
print("🔒 Starting eye tracking...")
|
||||||
|
try await eyeTrackingService.startEyeTracking()
|
||||||
|
isEnforceModeActive = true
|
||||||
|
print("✓ Enforce mode enabled")
|
||||||
|
} catch {
|
||||||
|
print("⚠️ Failed to enable enforce mode: \(error.localizedDescription)")
|
||||||
|
isEnforceModeActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableEnforceMode() {
|
||||||
|
guard isEnforceModeActive else { return }
|
||||||
|
|
||||||
|
eyeTrackingService.stopEyeTracking()
|
||||||
|
isEnforceModeActive = false
|
||||||
|
userCompliedWithBreak = false
|
||||||
|
print("✓ Enforce mode disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTimerEngine(_ engine: TimerEngine) {
|
||||||
|
self.timerEngine = engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool {
|
||||||
|
guard isEnforceModeActive else { return false }
|
||||||
|
guard settingsManager.settings.enforcementMode else { return false }
|
||||||
|
|
||||||
|
switch timerIdentifier {
|
||||||
|
case .builtIn(let type):
|
||||||
|
return type == .lookAway
|
||||||
|
case .user:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkUserCompliance() {
|
||||||
|
guard isEnforceModeActive else {
|
||||||
|
userCompliedWithBreak = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lookingAway = !eyeTrackingService.userLookingAtScreen
|
||||||
|
userCompliedWithBreak = lookingAway
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleGazeChange(lookingAtScreen: Bool) {
|
||||||
|
guard isEnforceModeActive else { return }
|
||||||
|
|
||||||
|
checkUserCompliance()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startEnforcementForActiveReminder() {
|
||||||
|
guard let engine = timerEngine else { return }
|
||||||
|
guard let activeReminder = engine.activeReminder else { return }
|
||||||
|
|
||||||
|
switch activeReminder {
|
||||||
|
case .lookAwayTriggered:
|
||||||
|
if shouldEnforceBreak(for: .builtIn(.lookAway)) {
|
||||||
|
checkUserCompliance()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
Gaze/Services/EyeTrackingService.swift
Normal file
218
Gaze/Services/EyeTrackingService.swift
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
//
|
||||||
|
// EyeTrackingService.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/13/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
import Vision
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class EyeTrackingService: NSObject, ObservableObject {
|
||||||
|
static let shared = EyeTrackingService()
|
||||||
|
|
||||||
|
@Published var isEyeTrackingActive = false
|
||||||
|
@Published var isEyesClosed = false
|
||||||
|
@Published var userLookingAtScreen = true
|
||||||
|
@Published var faceDetected = false
|
||||||
|
|
||||||
|
private var captureSession: AVCaptureSession?
|
||||||
|
private var videoOutput: AVCaptureVideoDataOutput?
|
||||||
|
private let videoDataOutputQueue = DispatchQueue(label: "com.gaze.videoDataOutput", qos: .userInitiated)
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startEyeTracking() async throws {
|
||||||
|
print("👁️ startEyeTracking called")
|
||||||
|
guard !isEyeTrackingActive else {
|
||||||
|
print("⚠️ Eye tracking already active")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cameraService = CameraAccessService.shared
|
||||||
|
print("👁️ Camera authorized: \(cameraService.isCameraAuthorized)")
|
||||||
|
|
||||||
|
if !cameraService.isCameraAuthorized {
|
||||||
|
print("👁️ Requesting camera access...")
|
||||||
|
try await cameraService.requestCameraAccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard cameraService.isCameraAuthorized else {
|
||||||
|
print("❌ Camera access denied")
|
||||||
|
throw CameraAccessError.accessDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
print("👁️ Setting up capture session...")
|
||||||
|
try await setupCaptureSession()
|
||||||
|
|
||||||
|
print("👁️ Starting capture session...")
|
||||||
|
captureSession?.startRunning()
|
||||||
|
isEyeTrackingActive = true
|
||||||
|
print("✓ Eye tracking active")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopEyeTracking() {
|
||||||
|
captureSession?.stopRunning()
|
||||||
|
captureSession = nil
|
||||||
|
videoOutput = nil
|
||||||
|
isEyeTrackingActive = false
|
||||||
|
isEyesClosed = false
|
||||||
|
userLookingAtScreen = true
|
||||||
|
faceDetected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCaptureSession() async throws {
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
session.sessionPreset = .vga640x480
|
||||||
|
|
||||||
|
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
|
||||||
|
throw EyeTrackingError.noCamera
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||||
|
guard session.canAddInput(videoInput) else {
|
||||||
|
throw EyeTrackingError.cannotAddInput
|
||||||
|
}
|
||||||
|
session.addInput(videoInput)
|
||||||
|
|
||||||
|
let output = AVCaptureVideoDataOutput()
|
||||||
|
output.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
|
||||||
|
output.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
|
||||||
|
output.alwaysDiscardsLateVideoFrames = true
|
||||||
|
|
||||||
|
guard session.canAddOutput(output) else {
|
||||||
|
throw EyeTrackingError.cannotAddOutput
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
|
||||||
|
self.captureSession = session
|
||||||
|
self.videoOutput = output
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processFaceObservations(_ observations: [VNFaceObservation]?) {
|
||||||
|
guard let observations = observations, !observations.isEmpty else {
|
||||||
|
faceDetected = false
|
||||||
|
userLookingAtScreen = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
faceDetected = true
|
||||||
|
|
||||||
|
guard let face = observations.first,
|
||||||
|
let landmarks = face.landmarks else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let leftEye = landmarks.leftEye,
|
||||||
|
let rightEye = landmarks.rightEye {
|
||||||
|
let eyesClosed = detectEyesClosed(leftEye: leftEye, rightEye: rightEye)
|
||||||
|
self.isEyesClosed = eyesClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
let lookingAway = detectLookingAway(face: face, landmarks: landmarks)
|
||||||
|
userLookingAtScreen = !lookingAway
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectEyesClosed(leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D) -> Bool {
|
||||||
|
guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftEyeHeight = calculateEyeHeight(leftEye)
|
||||||
|
let rightEyeHeight = calculateEyeHeight(rightEye)
|
||||||
|
|
||||||
|
let closedThreshold: CGFloat = 0.02
|
||||||
|
|
||||||
|
return leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateEyeHeight(_ eye: VNFaceLandmarkRegion2D) -> CGFloat {
|
||||||
|
let points = eye.normalizedPoints
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectLookingAway(face: VNFaceObservation, landmarks: VNFaceLandmarks2D) -> Bool {
|
||||||
|
let yaw = face.yaw?.doubleValue ?? 0.0
|
||||||
|
let roll = face.roll?.doubleValue ?? 0.0
|
||||||
|
|
||||||
|
let yawThreshold = 0.35
|
||||||
|
let rollThreshold = 0.4
|
||||||
|
|
||||||
|
let isLookingAway = abs(yaw) > yawThreshold || abs(roll) > rollThreshold
|
||||||
|
|
||||||
|
return isLookingAway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate
|
||||||
|
|
||||||
|
extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
nonisolated func captureOutput(
|
||||||
|
_ output: AVCaptureOutput,
|
||||||
|
didOutput sampleBuffer: CMSampleBuffer,
|
||||||
|
from connection: AVCaptureConnection
|
||||||
|
) {
|
||||||
|
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = VNDetectFaceLandmarksRequest { [weak self] request, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
print("Face detection error: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
self.processFaceObservations(request.results as? [VNFaceObservation])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.revision = VNDetectFaceLandmarksRequestRevision3
|
||||||
|
|
||||||
|
let imageRequestHandler = VNImageRequestHandler(
|
||||||
|
cvPixelBuffer: pixelBuffer,
|
||||||
|
orientation: .leftMirrored,
|
||||||
|
options: [:]
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try imageRequestHandler.perform([request])
|
||||||
|
} catch {
|
||||||
|
print("Failed to perform face detection: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error Handling
|
||||||
|
|
||||||
|
enum EyeTrackingError: Error, LocalizedError {
|
||||||
|
case noCamera
|
||||||
|
case cannotAddInput
|
||||||
|
case cannotAddOutput
|
||||||
|
case visionRequestFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noCamera:
|
||||||
|
return "No camera device available."
|
||||||
|
case .cannotAddInput:
|
||||||
|
return "Cannot add camera input to capture session."
|
||||||
|
case .cannotAddOutput:
|
||||||
|
return "Cannot add video output to capture session."
|
||||||
|
case .visionRequestFailed:
|
||||||
|
return "Vision face detection request failed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,16 @@ class TimerEngine: ObservableObject {
|
|||||||
private let settingsManager: SettingsManager
|
private let settingsManager: SettingsManager
|
||||||
private var sleepStartTime: Date?
|
private var sleepStartTime: Date?
|
||||||
|
|
||||||
|
// For enforce mode integration
|
||||||
|
private var enforceModeService: EnforceModeService?
|
||||||
|
|
||||||
init(settingsManager: SettingsManager) {
|
init(settingsManager: SettingsManager) {
|
||||||
self.settingsManager = settingsManager
|
self.settingsManager = settingsManager
|
||||||
|
self.enforceModeService = EnforceModeService.shared
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
self.enforceModeService?.setTimerEngine(self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -70,6 +78,14 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if enforce mode is active and should affect timer behavior
|
||||||
|
func checkEnforceMode() {
|
||||||
|
guard let enforceService = enforceModeService else { return }
|
||||||
|
guard enforceService.isEnforceModeActive else { return }
|
||||||
|
|
||||||
|
enforceService.startEnforcementForActiveReminder()
|
||||||
|
}
|
||||||
|
|
||||||
private func updateConfigurations() {
|
private func updateConfigurations() {
|
||||||
var newStates: [TimerIdentifier: TimerState] = [:]
|
var newStates: [TimerIdentifier: TimerState] = [:]
|
||||||
|
|
||||||
@@ -201,21 +217,13 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleTick() {
|
private func handleTick() {
|
||||||
// Handle all timers uniformly - only skip the timer that has an active reminder
|
|
||||||
for (identifier, state) in timerStates {
|
for (identifier, state) in timerStates {
|
||||||
guard state.isActive && !state.isPaused else { continue }
|
guard !state.isPaused else { continue }
|
||||||
|
guard state.isActive else { continue }
|
||||||
|
|
||||||
// Skip the timer that triggered the current reminder
|
if state.targetDate < Date() - 3.0 {
|
||||||
if let activeReminder = activeReminder, activeReminder.identifier == identifier {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// prevent overshoot - in case user closes laptop while timer is running, we don't want to
|
|
||||||
// trigger on open
|
|
||||||
if state.targetDate < Date() - 3.0 { // slight grace
|
|
||||||
// Reset the timer when it has overshot its interval
|
|
||||||
skipNext(identifier: identifier)
|
skipNext(identifier: identifier)
|
||||||
continue // Skip normal countdown logic after reset
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
timerStates[identifier]?.remainingSeconds -= 1
|
timerStates[identifier]?.remainingSeconds -= 1
|
||||||
@@ -225,6 +233,8 @@ class TimerEngine: ObservableObject {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkEnforceMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerReminder(for identifier: TimerIdentifier) {
|
func triggerReminder(for identifier: TimerIdentifier) {
|
||||||
|
|||||||
@@ -37,13 +37,19 @@ struct SettingsWindowView: View {
|
|||||||
Label("Posture", systemImage: "figure.stand")
|
Label("Posture", systemImage: "figure.stand")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnforceModeSetupView(settingsManager: settingsManager)
|
||||||
|
.tag(3)
|
||||||
|
.tabItem {
|
||||||
|
Label("Enforce Mode", systemImage: "video.fill")
|
||||||
|
}
|
||||||
|
|
||||||
UserTimersView(
|
UserTimersView(
|
||||||
userTimers: Binding(
|
userTimers: Binding(
|
||||||
get: { settingsManager.settings.userTimers },
|
get: { settingsManager.settings.userTimers },
|
||||||
set: { settingsManager.settings.userTimers = $0 }
|
set: { settingsManager.settings.userTimers = $0 }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.tag(3)
|
.tag(4)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("User Timers", systemImage: "plus.circle")
|
Label("User Timers", systemImage: "plus.circle")
|
||||||
}
|
}
|
||||||
@@ -52,7 +58,7 @@ struct SettingsWindowView: View {
|
|||||||
settingsManager: settingsManager,
|
settingsManager: settingsManager,
|
||||||
isOnboarding: false
|
isOnboarding: false
|
||||||
)
|
)
|
||||||
.tag(4)
|
.tag(5)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("General", systemImage: "gearshape.fill")
|
Label("General", systemImage: "gearshape.fill")
|
||||||
}
|
}
|
||||||
|
|||||||
231
Gaze/Views/Setup/EnforceModeSetupView.swift
Normal file
231
Gaze/Views/Setup/EnforceModeSetupView.swift
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
//
|
||||||
|
// EnforceModeSetupView.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/13/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EnforceModeSetupView: View {
|
||||||
|
@ObservedObject var settingsManager: SettingsManager
|
||||||
|
@ObservedObject var cameraService = CameraAccessService.shared
|
||||||
|
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
|
||||||
|
@ObservedObject var enforceModeService = EnforceModeService.shared
|
||||||
|
|
||||||
|
@State private var isProcessingToggle = 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)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
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("Uses camera to detect when you look away from screen")
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(isProcessingToggle)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
|
||||||
|
cameraStatusView
|
||||||
|
|
||||||
|
if enforceModeService.isEnforceModeActive {
|
||||||
|
eyeTrackingStatusView
|
||||||
|
}
|
||||||
|
|
||||||
|
privacyInfoView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cameraStatusView: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Camera Access")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if cameraService.isCameraAuthorized {
|
||||||
|
Label("Authorized", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
} else if let error = cameraService.cameraError {
|
||||||
|
Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
} else {
|
||||||
|
Label("Not authorized", systemImage: "xmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !cameraService.isCameraAuthorized {
|
||||||
|
Button("Request Access") {
|
||||||
|
print("📷 Request Access button clicked")
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try await cameraService.requestCameraAccess()
|
||||||
|
print("✓ Camera access granted via button")
|
||||||
|
} catch {
|
||||||
|
print("⚠️ Camera access failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var eyeTrackingStatusView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Eye Tracking Status")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
statusIndicator(
|
||||||
|
title: "Face Detected",
|
||||||
|
isActive: eyeTrackingService.faceDetected,
|
||||||
|
icon: "person.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
statusIndicator(
|
||||||
|
title: "Looking at Screen",
|
||||||
|
isActive: eyeTrackingService.userLookingAtScreen,
|
||||||
|
icon: "eye.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
statusIndicator(
|
||||||
|
title: "Eyes Closed",
|
||||||
|
isActive: eyeTrackingService.isEyesClosed,
|
||||||
|
icon: "eye.slash.fill"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(isActive ? .green : .secondary)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var privacyInfoView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "lock.shield.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text("Privacy Information")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
privacyBullet("All processing happens on-device")
|
||||||
|
privacyBullet("No images are stored or transmitted")
|
||||||
|
privacyBullet("Camera only active during lookaway reminders")
|
||||||
|
privacyBullet("You can disable at any time")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func privacyBullet(_ text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEnforceModeToggle(enabled: Bool) {
|
||||||
|
print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)")
|
||||||
|
isProcessingToggle = true
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
defer { isProcessingToggle = false }
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
print("🎛️ Enabling enforce mode...")
|
||||||
|
await enforceModeService.enableEnforceMode()
|
||||||
|
print("🎛️ Enforce mode enabled, isActive: \(enforceModeService.isEnforceModeActive)")
|
||||||
|
|
||||||
|
if !enforceModeService.isEnforceModeActive {
|
||||||
|
print("⚠️ Failed to activate, reverting toggle")
|
||||||
|
settingsManager.settings.enforcementMode = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("🎛️ Disabling enforce mode...")
|
||||||
|
enforceModeService.disableEnforceMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
EnforceModeSetupView(settingsManager: SettingsManager.shared)
|
||||||
|
}
|
||||||
34
GazeTests/Services/CameraAccessServiceTests.swift
Normal file
34
GazeTests/Services/CameraAccessServiceTests.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// CameraAccessServiceTests.swift
|
||||||
|
// GazeTests
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/13/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import Gaze
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CameraAccessServiceTests: XCTestCase {
|
||||||
|
var cameraService: CameraAccessService!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
cameraService = CameraAccessService.shared
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCameraServiceInitialization() {
|
||||||
|
XCTAssertNotNil(cameraService)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheckCameraAuthorizationStatus() {
|
||||||
|
cameraService.checkCameraAuthorizationStatus()
|
||||||
|
|
||||||
|
XCTAssertFalse(cameraService.isCameraAuthorized || cameraService.cameraError != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIsFaceDetectionAvailable() {
|
||||||
|
let isAvailable = cameraService.isFaceDetectionAvailable()
|
||||||
|
|
||||||
|
XCTAssertEqual(isAvailable, cameraService.isCameraAuthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
GazeTests/Services/EnforceModeServiceTests.swift
Normal file
57
GazeTests/Services/EnforceModeServiceTests.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// EnforceModeServiceTests.swift
|
||||||
|
// GazeTests
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/13/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import Gaze
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class EnforceModeServiceTests: XCTestCase {
|
||||||
|
var enforceModeService: EnforceModeService!
|
||||||
|
var settingsManager: SettingsManager!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
settingsManager = SettingsManager.shared
|
||||||
|
enforceModeService = EnforceModeService.shared
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
enforceModeService.disableEnforceMode()
|
||||||
|
settingsManager.settings.enforcementMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnforceModeServiceInitialization() {
|
||||||
|
XCTAssertNotNil(enforceModeService)
|
||||||
|
XCTAssertFalse(enforceModeService.isEnforceModeActive)
|
||||||
|
XCTAssertFalse(enforceModeService.userCompliedWithBreak)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisableEnforceModeResetsState() {
|
||||||
|
enforceModeService.disableEnforceMode()
|
||||||
|
|
||||||
|
XCTAssertFalse(enforceModeService.isEnforceModeActive)
|
||||||
|
XCTAssertFalse(enforceModeService.userCompliedWithBreak)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShouldEnforceBreakOnlyForLookAwayTimer() {
|
||||||
|
settingsManager.settings.enforcementMode = true
|
||||||
|
|
||||||
|
let shouldEnforceLookAway = enforceModeService.shouldEnforceBreak(for: .builtIn(.lookAway))
|
||||||
|
XCTAssertFalse(shouldEnforceLookAway)
|
||||||
|
|
||||||
|
let shouldEnforceBlink = enforceModeService.shouldEnforceBreak(for: .builtIn(.blink))
|
||||||
|
XCTAssertFalse(shouldEnforceBlink)
|
||||||
|
|
||||||
|
let shouldEnforcePosture = enforceModeService.shouldEnforceBreak(for: .builtIn(.posture))
|
||||||
|
XCTAssertFalse(shouldEnforcePosture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheckUserComplianceWhenNotActive() {
|
||||||
|
enforceModeService.checkUserCompliance()
|
||||||
|
|
||||||
|
XCTAssertFalse(enforceModeService.userCompliedWithBreak)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
GazeTests/Services/EyeTrackingServiceTests.swift
Normal file
39
GazeTests/Services/EyeTrackingServiceTests.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// EyeTrackingServiceTests.swift
|
||||||
|
// GazeTests
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/13/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import Gaze
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class EyeTrackingServiceTests: XCTestCase {
|
||||||
|
var eyeTrackingService: EyeTrackingService!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
eyeTrackingService = EyeTrackingService.shared
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
eyeTrackingService.stopEyeTracking()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEyeTrackingServiceInitialization() {
|
||||||
|
XCTAssertNotNil(eyeTrackingService)
|
||||||
|
XCTAssertFalse(eyeTrackingService.isEyeTrackingActive)
|
||||||
|
XCTAssertFalse(eyeTrackingService.isEyesClosed)
|
||||||
|
XCTAssertTrue(eyeTrackingService.userLookingAtScreen)
|
||||||
|
XCTAssertFalse(eyeTrackingService.faceDetected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStopEyeTrackingResetsState() {
|
||||||
|
eyeTrackingService.stopEyeTracking()
|
||||||
|
|
||||||
|
XCTAssertFalse(eyeTrackingService.isEyeTrackingActive)
|
||||||
|
XCTAssertFalse(eyeTrackingService.isEyesClosed)
|
||||||
|
XCTAssertTrue(eyeTrackingService.userLookingAtScreen)
|
||||||
|
XCTAssertFalse(eyeTrackingService.faceDetected)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user