feat: continued enforce mode implementation
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
|
||||
@@ -32,5 +32,7 @@
|
||||
<integer>86400</integer>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -20,20 +20,28 @@ class CameraAccessService: ObservableObject {
|
||||
}
|
||||
|
||||
func requestCameraAccess() async throws {
|
||||
print("🎥 Requesting camera access...")
|
||||
|
||||
guard #available(macOS 12.0, *) else {
|
||||
print("⚠️ macOS version too old")
|
||||
throw CameraAccessError.unsupportedOS
|
||||
}
|
||||
|
||||
if isCameraAuthorized {
|
||||
print("✓ Camera already authorized")
|
||||
return
|
||||
}
|
||||
|
||||
print("🎥 Calling AVCaptureDevice.requestAccess...")
|
||||
let status = await AVCaptureDevice.requestAccess(for: .video)
|
||||
print("🎥 Permission result: \(status)")
|
||||
|
||||
if !status {
|
||||
throw CameraAccessError.accessDenied
|
||||
}
|
||||
|
||||
checkCameraAuthorizationStatus()
|
||||
print("✓ Camera access granted")
|
||||
}
|
||||
|
||||
func checkCameraAuthorizationStatus() {
|
||||
@@ -59,6 +67,13 @@ class CameraAccessService: ObservableObject {
|
||||
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
|
||||
|
||||
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 var sleepStartTime: Date?
|
||||
|
||||
// For enforce mode integration
|
||||
private var enforceModeService: EnforceModeService?
|
||||
|
||||
init(settingsManager: SettingsManager) {
|
||||
self.settingsManager = settingsManager
|
||||
self.enforceModeService = EnforceModeService.shared
|
||||
|
||||
Task { @MainActor in
|
||||
self.enforceModeService?.setTimerEngine(self)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
var newStates: [TimerIdentifier: TimerState] = [:]
|
||||
|
||||
@@ -201,21 +217,13 @@ class TimerEngine: ObservableObject {
|
||||
}
|
||||
|
||||
private func handleTick() {
|
||||
// Handle all timers uniformly - only skip the timer that has an active reminder
|
||||
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 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
|
||||
if state.targetDate < Date() - 3.0 {
|
||||
skipNext(identifier: identifier)
|
||||
continue // Skip normal countdown logic after reset
|
||||
continue
|
||||
}
|
||||
|
||||
timerStates[identifier]?.remainingSeconds -= 1
|
||||
@@ -225,6 +233,8 @@ class TimerEngine: ObservableObject {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
checkEnforceMode()
|
||||
}
|
||||
|
||||
func triggerReminder(for identifier: TimerIdentifier) {
|
||||
|
||||
@@ -37,13 +37,19 @@ struct SettingsWindowView: View {
|
||||
Label("Posture", systemImage: "figure.stand")
|
||||
}
|
||||
|
||||
EnforceModeSetupView(settingsManager: settingsManager)
|
||||
.tag(3)
|
||||
.tabItem {
|
||||
Label("Enforce Mode", systemImage: "video.fill")
|
||||
}
|
||||
|
||||
UserTimersView(
|
||||
userTimers: Binding(
|
||||
get: { settingsManager.settings.userTimers },
|
||||
set: { settingsManager.settings.userTimers = $0 }
|
||||
)
|
||||
)
|
||||
.tag(3)
|
||||
.tag(4)
|
||||
.tabItem {
|
||||
Label("User Timers", systemImage: "plus.circle")
|
||||
}
|
||||
@@ -52,7 +58,7 @@ struct SettingsWindowView: View {
|
||||
settingsManager: settingsManager,
|
||||
isOnboarding: false
|
||||
)
|
||||
.tag(4)
|
||||
.tag(5)
|
||||
.tabItem {
|
||||
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