diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift index 62b9595..729bba0 100644 --- a/Gaze/Services/EnforceModeService.swift +++ b/Gaze/Services/EnforceModeService.swift @@ -12,8 +12,10 @@ import Foundation class EnforceModeService: ObservableObject { static let shared = EnforceModeService() - @Published var isEnforceModeActive = false + @Published var isEnforceModeEnabled = false + @Published var isCameraActive = false @Published var userCompliedWithBreak = false + @Published var isTestMode = false private var settingsManager: SettingsManager private var eyeTrackingService: EyeTrackingService @@ -37,27 +39,36 @@ class EnforceModeService: ObservableObject { func enableEnforceMode() async { print("๐Ÿ”’ enableEnforceMode called") - guard !isEnforceModeActive else { - print("โš ๏ธ Enforce mode already active") + guard !isEnforceModeEnabled else { + print("โš ๏ธ Enforce mode already enabled") 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 + let cameraService = CameraAccessService.shared + if !cameraService.isCameraAuthorized { + do { + print("๐Ÿ”’ Requesting camera permission...") + try await cameraService.requestCameraAccess() + } catch { + print("โš ๏ธ Failed to get camera permission: \(error.localizedDescription)") + return + } } + + guard cameraService.isCameraAuthorized else { + print("โŒ Camera permission denied") + return + } + + isEnforceModeEnabled = true + print("โœ“ Enforce mode enabled (camera will activate before lookaway reminders)") } func disableEnforceMode() { - guard isEnforceModeActive else { return } + guard isEnforceModeEnabled else { return } - eyeTrackingService.stopEyeTracking() - isEnforceModeActive = false + stopCamera() + isEnforceModeEnabled = false userCompliedWithBreak = false print("โœ“ Enforce mode disabled") } @@ -67,7 +78,7 @@ class EnforceModeService: ObservableObject { } func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool { - guard isEnforceModeActive else { return false } + guard isEnforceModeEnabled else { return false } guard settingsManager.settings.enforcementMode else { return false } switch timerIdentifier { @@ -78,8 +89,32 @@ class EnforceModeService: ObservableObject { } } + func startCameraForLookawayTimer(secondsRemaining: Int) async { + guard isEnforceModeEnabled else { return } + guard !isCameraActive else { return } + + print("๐Ÿ‘๏ธ Starting camera for lookaway reminder (T-\(secondsRemaining)s)") + + do { + try await eyeTrackingService.startEyeTracking() + isCameraActive = true + print("โœ“ Camera active") + } catch { + print("โš ๏ธ Failed to start camera: \(error.localizedDescription)") + } + } + + func stopCamera() { + guard isCameraActive else { return } + + print("๐Ÿ‘๏ธ Stopping camera") + eyeTrackingService.stopEyeTracking() + isCameraActive = false + userCompliedWithBreak = false + } + func checkUserCompliance() { - guard isEnforceModeActive else { + guard isCameraActive else { userCompliedWithBreak = false return } @@ -89,22 +124,37 @@ class EnforceModeService: ObservableObject { } private func handleGazeChange(lookingAtScreen: Bool) { - guard isEnforceModeActive else { return } + guard isCameraActive else { return } checkUserCompliance() } - func startEnforcementForActiveReminder() { - guard let engine = timerEngine else { return } - guard let activeReminder = engine.activeReminder else { return } + func handleReminderDismissed() { + stopCamera() + } + + func startTestMode() async { + guard isEnforceModeEnabled else { return } + guard !isCameraActive else { return } - switch activeReminder { - case .lookAwayTriggered: - if shouldEnforceBreak(for: .builtIn(.lookAway)) { - checkUserCompliance() - } - default: - break + print("๐Ÿงช Starting test mode") + isTestMode = true + + do { + try await eyeTrackingService.startEyeTracking() + isCameraActive = true + print("โœ“ Test mode camera active") + } catch { + print("โš ๏ธ Failed to start test mode camera: \(error.localizedDescription)") + isTestMode = false } } + + func stopTestMode() { + guard isTestMode else { return } + + print("๐Ÿงช Stopping test mode") + stopCamera() + isTestMode = false + } } \ No newline at end of file diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index 1fdfa29..84d3682 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -22,6 +22,13 @@ class EyeTrackingService: NSObject, ObservableObject { private var videoOutput: AVCaptureVideoDataOutput? private let videoDataOutputQueue = DispatchQueue(label: "com.gaze.videoDataOutput", qos: .userInitiated) + var previewLayer: AVCaptureVideoPreviewLayer? { + guard let session = captureSession else { return nil } + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + return layer + } + private override init() { super.init() } diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index d13d35a..d77efe1 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -80,10 +80,7 @@ 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() + // Deprecated - camera is now activated in handleTick before timer triggers } private func updateConfigurations() { @@ -210,10 +207,11 @@ class TimerEngine: ObservableObject { guard let reminder = activeReminder else { return } activeReminder = nil - // Skip to next interval and resume the timer that was paused let identifier = reminder.identifier skipNext(identifier: identifier) resumeTimer(identifier: identifier) + + enforceModeService?.handleReminderDismissed() } private func handleTick() { @@ -228,13 +226,23 @@ class TimerEngine: ObservableObject { timerStates[identifier]?.remainingSeconds -= 1 - if let updatedState = timerStates[identifier], updatedState.remainingSeconds <= 0 { - triggerReminder(for: identifier) - break + if let updatedState = timerStates[identifier] { + if updatedState.remainingSeconds <= 3 && !updatedState.isPaused { + if case .builtIn(.lookAway) = identifier { + if enforceModeService?.shouldEnforceBreak(for: identifier) == true { + Task { @MainActor in + await enforceModeService?.startCameraForLookawayTimer(secondsRemaining: updatedState.remainingSeconds) + } + } + } + } + + if updatedState.remainingSeconds <= 0 { + triggerReminder(for: identifier) + break + } } } - - checkEnforceMode() } func triggerReminder(for identifier: TimerIdentifier) { diff --git a/Gaze/Views/Components/CameraPreviewView.swift b/Gaze/Views/Components/CameraPreviewView.swift new file mode 100644 index 0000000..c30a385 --- /dev/null +++ b/Gaze/Views/Components/CameraPreviewView.swift @@ -0,0 +1,48 @@ +// +// CameraPreviewView.swift +// Gaze +// +// Created by Mike Freno on 1/14/26. +// + +import SwiftUI +import AVFoundation + +struct CameraPreviewView: NSViewRepresentable { + let previewLayer: AVCaptureVideoPreviewLayer + let borderColor: NSColor + + func makeNSView(context: Context) -> NSView { + let view = PreviewContainerView() + view.wantsLayer = true + + previewLayer.frame = view.bounds + view.layer?.addSublayer(previewLayer) + + updateBorder(view: view, color: borderColor) + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + previewLayer.frame = nsView.bounds + updateBorder(view: nsView, color: borderColor) + } + + private func updateBorder(view: NSView, color: NSColor) { + view.layer?.borderColor = color.cgColor + view.layer?.borderWidth = 4 + view.layer?.cornerRadius = 12 + view.layer?.masksToBounds = true + } + + class PreviewContainerView: NSView { + override func layout() { + super.layout() + // Update sublayer frames when view is resized + if let previewLayer = layer?.sublayers?.first as? AVCaptureVideoPreviewLayer { + previewLayer.frame = bounds + } + } + } +} diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index 298edd0..fc8db80 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -14,6 +14,7 @@ struct EnforceModeSetupView: View { @ObservedObject var enforceModeService = EnforceModeService.shared @State private var isProcessingToggle = false + @State private var isTestModeActive = false var body: some View { VStack(spacing: 0) { @@ -40,7 +41,7 @@ struct EnforceModeSetupView: View { VStack(alignment: .leading, spacing: 4) { Text("Enable Enforce Mode") .font(.headline) - Text("Uses camera to detect when you look away from screen") + Text("Camera activates 3 seconds before lookaway reminders") .font(.caption) .foregroundColor(.secondary) } @@ -70,11 +71,21 @@ struct EnforceModeSetupView: View { cameraStatusView - if enforceModeService.isEnforceModeActive { - eyeTrackingStatusView + if enforceModeService.isEnforceModeEnabled { + testModeButton } - privacyInfoView + if isTestModeActive && enforceModeService.isCameraActive { + testModePreviewView + } else { + if enforceModeService.isCameraActive && !isTestModeActive { + eyeTrackingStatusView + } else if enforceModeService.isEnforceModeEnabled { + cameraPendingView + } + + privacyInfoView + } } } @@ -85,6 +96,71 @@ struct EnforceModeSetupView: View { .background(.clear) } + private var testModeButton: some View { + Button(action: { + Task { @MainActor in + if isTestModeActive { + enforceModeService.stopTestMode() + isTestModeActive = false + } else { + await enforceModeService.startTestMode() + isTestModeActive = enforceModeService.isCameraActive + } + } + }) { + HStack { + Image(systemName: isTestModeActive ? "stop.circle.fill" : "play.circle.fill") + .font(.title3) + Text(isTestModeActive ? "Stop Test" : "Test Tracking") + .font(.headline) + } + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + + private var testModePreviewView: some View { + VStack(spacing: 16) { + if let previewLayer = eyeTrackingService.previewLayer { + let lookingAway = !eyeTrackingService.userLookingAtScreen + let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed + + CameraPreviewView(previewLayer: previewLayer, borderColor: borderColor) + .frame(height: 300) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 12) { + Text("Live Tracking Status") + .font(.headline) + + HStack(spacing: 20) { + statusIndicator( + title: "Face Detected", + isActive: eyeTrackingService.faceDetected, + icon: "person.fill" + ) + + statusIndicator( + title: "Looking Away", + isActive: !eyeTrackingService.userLookingAtScreen, + icon: "arrow.turn.up.right" + ) + } + + Text(lookingAway ? "โœ“ Break compliance detected" : "โš ๏ธ Please look away from screen") + .font(.caption) + .foregroundColor(lookingAway ? .green : .orange) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + } + } + private var cameraStatusView: some View { HStack { VStack(alignment: .leading, spacing: 4) { @@ -140,15 +216,9 @@ struct EnforceModeSetupView: View { ) statusIndicator( - title: "Looking at Screen", - isActive: eyeTrackingService.userLookingAtScreen, - icon: "eye.fill" - ) - - statusIndicator( - title: "Eyes Closed", - isActive: eyeTrackingService.isEyesClosed, - icon: "eye.slash.fill" + title: "Looking Away", + isActive: !eyeTrackingService.userLookingAtScreen, + icon: "arrow.turn.up.right" ) } } @@ -156,6 +226,26 @@ struct EnforceModeSetupView: View { .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) } + private var cameraPendingView: some View { + HStack { + Image(systemName: "timer") + .font(.title2) + .foregroundColor(.orange) + + VStack(alignment: .leading, spacing: 4) { + Text("Camera Ready") + .font(.headline) + Text("Will activate 3 seconds before lookaway reminder") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .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) @@ -183,7 +273,8 @@ struct EnforceModeSetupView: View { 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("Camera only active during lookaway reminders (3 second window)") + privacyBullet("Eyes closed does not affect countdown") privacyBullet("You can disable at any time") } .font(.caption) @@ -212,9 +303,9 @@ struct EnforceModeSetupView: View { if enabled { print("๐ŸŽ›๏ธ Enabling enforce mode...") await enforceModeService.enableEnforceMode() - print("๐ŸŽ›๏ธ Enforce mode enabled, isActive: \(enforceModeService.isEnforceModeActive)") + print("๐ŸŽ›๏ธ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)") - if !enforceModeService.isEnforceModeActive { + if !enforceModeService.isEnforceModeEnabled { print("โš ๏ธ Failed to activate, reverting toggle") settingsManager.settings.enforcementMode = false } diff --git a/GazeTests/Services/EnforceModeServiceTests.swift b/GazeTests/Services/EnforceModeServiceTests.swift index 8a19f84..c4db965 100644 --- a/GazeTests/Services/EnforceModeServiceTests.swift +++ b/GazeTests/Services/EnforceModeServiceTests.swift @@ -25,14 +25,16 @@ final class EnforceModeServiceTests: XCTestCase { func testEnforceModeServiceInitialization() { XCTAssertNotNil(enforceModeService) - XCTAssertFalse(enforceModeService.isEnforceModeActive) + XCTAssertFalse(enforceModeService.isEnforceModeEnabled) + XCTAssertFalse(enforceModeService.isCameraActive) XCTAssertFalse(enforceModeService.userCompliedWithBreak) } func testDisableEnforceModeResetsState() { enforceModeService.disableEnforceMode() - XCTAssertFalse(enforceModeService.isEnforceModeActive) + XCTAssertFalse(enforceModeService.isEnforceModeEnabled) + XCTAssertFalse(enforceModeService.isCameraActive) XCTAssertFalse(enforceModeService.userCompliedWithBreak) }