diff --git a/Gaze/Protocols/TimeProviding.swift b/Gaze/Protocols/TimeProviding.swift index e8ce673..0442af8 100644 --- a/Gaze/Protocols/TimeProviding.swift +++ b/Gaze/Protocols/TimeProviding.swift @@ -19,26 +19,3 @@ struct SystemTimeProvider: TimeProviding { Date() } } - -/// Test implementation that allows manual time control -final class MockTimeProvider: TimeProviding, @unchecked Sendable { - private var currentTime: Date - - init(startTime: Date = Date()) { - self.currentTime = startTime - } - - func now() -> Date { - currentTime - } - - /// Advances time by the specified interval - func advance(by interval: TimeInterval) { - currentTime = currentTime.addingTimeInterval(interval) - } - - /// Sets the current time to a specific date - func setTime(_ date: Date) { - currentTime = date - } -} diff --git a/Gaze/Services/EnforceMode/EnforceCameraController.swift b/Gaze/Services/EnforceMode/EnforceCameraController.swift new file mode 100644 index 0000000..513b260 --- /dev/null +++ b/Gaze/Services/EnforceMode/EnforceCameraController.swift @@ -0,0 +1,95 @@ +// +// EnforceCameraController.swift +// Gaze +// +// Manages camera lifecycle for enforce mode sessions. +// + +import Combine +import Foundation + +protocol EnforceCameraControllerDelegate: AnyObject { + func cameraControllerDidTimeout(_ controller: EnforceCameraController) + func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool) +} + +@MainActor +final class EnforceCameraController: ObservableObject { + @Published private(set) var isCameraActive = false + @Published private(set) var lastFaceDetectionTime: Date = .distantPast + + weak var delegate: EnforceCameraControllerDelegate? + + private let eyeTrackingService: EyeTrackingService + private var cancellables = Set() + private var faceDetectionTimer: Timer? + var faceDetectionTimeout: TimeInterval = 5.0 + + init(eyeTrackingService: EyeTrackingService) { + self.eyeTrackingService = eyeTrackingService + setupObservers() + } + + func startCamera() async throws { + guard !isCameraActive else { return } + try await eyeTrackingService.startEyeTracking() + isCameraActive = true + lastFaceDetectionTime = Date() + startFaceDetectionTimer() + } + + func stopCamera() { + guard isCameraActive else { return } + eyeTrackingService.stopEyeTracking() + isCameraActive = false + stopFaceDetectionTimer() + } + + func resetFaceDetectionTimer() { + lastFaceDetectionTime = Date() + } + + private func setupObservers() { + eyeTrackingService.$userLookingAtScreen + .sink { [weak self] lookingAtScreen in + guard let self else { return } + self.delegate?.cameraController(self, didUpdateLookingAtScreen: lookingAtScreen) + } + .store(in: &cancellables) + + eyeTrackingService.$faceDetected + .sink { [weak self] faceDetected in + guard let self else { return } + if faceDetected { + self.lastFaceDetectionTime = Date() + } + } + .store(in: &cancellables) + } + + private func startFaceDetectionTimer() { + stopFaceDetectionTimer() + faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.checkFaceDetectionTimeout() + } + } + } + + private func stopFaceDetectionTimer() { + faceDetectionTimer?.invalidate() + faceDetectionTimer = nil + } + + private func checkFaceDetectionTimeout() { + guard isCameraActive else { + stopFaceDetectionTimer() + return + } + + let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime) + if timeSinceLastDetection > faceDetectionTimeout { + delegate?.cameraControllerDidTimeout(self) + } + } +} diff --git a/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift b/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift new file mode 100644 index 0000000..17f9d6e --- /dev/null +++ b/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift @@ -0,0 +1,53 @@ +// +// EnforcePolicyEvaluator.swift +// Gaze +// +// Policy evaluation for enforce mode behavior. +// + +import Foundation + +enum ComplianceResult { + case compliant + case notCompliant + case faceNotDetected +} + +final class EnforcePolicyEvaluator { + private let settingsProvider: any SettingsProviding + + init(settingsProvider: any SettingsProviding) { + self.settingsProvider = settingsProvider + } + + var isEnforcementEnabled: Bool { + settingsProvider.settings.enforcementMode + } + + func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool { + guard isEnforcementEnabled else { return false } + + switch timerIdentifier { + case .builtIn(let type): + return type == .lookAway + case .user: + return false + } + } + + func shouldPreActivateCamera( + timerIdentifier: TimerIdentifier, + secondsRemaining: Int + ) -> Bool { + guard secondsRemaining <= 3 else { return false } + return shouldEnforce(timerIdentifier: timerIdentifier) + } + + func evaluateCompliance( + isLookingAtScreen: Bool, + faceDetected: Bool + ) -> ComplianceResult { + guard faceDetected else { return .faceNotDetected } + return isLookingAtScreen ? .notCompliant : .compliant + } +} diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift index f3dc809..08b3282 100644 --- a/Gaze/Services/EnforceModeService.swift +++ b/Gaze/Services/EnforceModeService.swift @@ -18,39 +18,21 @@ class EnforceModeService: ObservableObject { @Published var isTestMode = false private var settingsManager: SettingsManager - private var eyeTrackingService: EyeTrackingService + private let policyEvaluator: EnforcePolicyEvaluator + private let cameraController: EnforceCameraController private var timerEngine: TimerEngine? - private var cancellables = Set() - private var faceDetectionTimer: Timer? - private var lastFaceDetectionTime: Date = Date.distantPast - private let faceDetectionTimeout: TimeInterval = 5.0 // 5 seconds to consider person lost - private init() { self.settingsManager = SettingsManager.shared - self.eyeTrackingService = EyeTrackingService.shared - setupObservers() + self.policyEvaluator = EnforcePolicyEvaluator(settingsProvider: SettingsManager.shared) + self.cameraController = EnforceCameraController(eyeTrackingService: EyeTrackingService.shared) + self.cameraController.delegate = self initializeEnforceModeState() } - private func setupObservers() { - eyeTrackingService.$userLookingAtScreen - .sink { [weak self] lookingAtScreen in - self?.handleGazeChange(lookingAtScreen: lookingAtScreen) - } - .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() { let cameraService = CameraAccessService.shared - let settingsEnabled = settingsManager.settings.enforcementMode + let settingsEnabled = policyEvaluator.isEnforcementEnabled // If settings say it's enabled AND camera is authorized, mark as enabled if settingsEnabled && cameraService.isCameraAuthorized { @@ -104,27 +86,17 @@ class EnforceModeService: ObservableObject { func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool { guard isEnforceModeEnabled else { return false } - guard settingsManager.settings.enforcementMode else { return false } - - switch timerIdentifier { - case .builtIn(let type): - return type == .lookAway - case .user: - return false - } + return policyEvaluator.shouldEnforce(timerIdentifier: timerIdentifier) } func startCameraForLookawayTimer(secondsRemaining: Int) async { guard isEnforceModeEnabled else { return } - guard !isCameraActive else { return } logDebug("๐Ÿ‘๏ธ Starting camera for lookaway reminder (T-\(secondsRemaining)s)") do { - try await eyeTrackingService.startEyeTracking() - isCameraActive = true - lastFaceDetectionTime = Date() // Reset grace period - startFaceDetectionTimer() + try await cameraController.startCamera() + isCameraActive = cameraController.isCameraActive logDebug("โœ“ Camera active") } catch { logError("โš ๏ธ Failed to start camera: \(error.localizedDescription)") @@ -135,11 +107,9 @@ class EnforceModeService: ObservableObject { guard isCameraActive else { return } logDebug("๐Ÿ‘๏ธ Stopping camera") - eyeTrackingService.stopEyeTracking() + cameraController.stopCamera() isCameraActive = false userCompliedWithBreak = false - - stopFaceDetectionTimer() } func checkUserCompliance() { @@ -147,54 +117,18 @@ class EnforceModeService: ObservableObject { userCompliedWithBreak = false return } + let compliance = policyEvaluator.evaluateCompliance( + isLookingAtScreen: EyeTrackingService.shared.userLookingAtScreen, + faceDetected: EyeTrackingService.shared.faceDetected + ) - let lookingAway = !eyeTrackingService.userLookingAtScreen - userCompliedWithBreak = lookingAway - } - - private func handleGazeChange(lookingAtScreen: Bool) { - guard isCameraActive else { return } - - checkUserCompliance() - } - - private func handleFaceDetectionChange(faceDetected: Bool) { - // Update the last face detection time only when a face is actively detected - if faceDetected { - lastFaceDetectionTime = Date() - } - } - - private func startFaceDetectionTimer() { - stopFaceDetectionTimer() - // Check every 1 second - faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { - [weak self] _ in - Task { @MainActor [weak self] in - self?.checkFaceDetectionTimeout() - } - } - } - - private func stopFaceDetectionTimer() { - faceDetectionTimer?.invalidate() - faceDetectionTimer = nil - } - - private func checkFaceDetectionTimeout() { - guard isEnforceModeEnabled && isCameraActive else { - stopFaceDetectionTimer() - return - } - - let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime) - - // If person has not been detected for too long, temporarily disable enforce mode - if timeSinceLastDetection > faceDetectionTimeout { - logDebug( - "โฐ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode." - ) - disableEnforceMode() + switch compliance { + case .compliant: + userCompliedWithBreak = true + case .notCompliant: + userCompliedWithBreak = false + case .faceNotDetected: + userCompliedWithBreak = false } } @@ -208,16 +142,13 @@ class EnforceModeService: ObservableObject { func startTestMode() async { guard isEnforceModeEnabled else { return } - guard !isCameraActive else { return } logDebug("๐Ÿงช Starting test mode") isTestMode = true do { - try await eyeTrackingService.startEyeTracking() - isCameraActive = true - lastFaceDetectionTime = Date() // Reset grace period - startFaceDetectionTimer() + try await cameraController.startCamera() + isCameraActive = cameraController.isCameraActive logDebug("โœ“ Test mode camera active") } catch { logError("โš ๏ธ Failed to start test mode camera: \(error.localizedDescription)") @@ -233,3 +164,17 @@ class EnforceModeService: ObservableObject { isTestMode = false } } + +extension EnforceModeService: EnforceCameraControllerDelegate { + func cameraControllerDidTimeout(_ controller: EnforceCameraController) { + logDebug( + "โฐ Person not detected for \(controller.faceDetectionTimeout)s. Temporarily disabling enforce mode." + ) + disableEnforceMode() + } + + func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool) { + guard isCameraActive else { return } + checkUserCompliance() + } +} diff --git a/Gaze/Services/EyeTracking/CalibrationBridge.swift b/Gaze/Services/EyeTracking/CalibrationBridge.swift new file mode 100644 index 0000000..ad9657f --- /dev/null +++ b/Gaze/Services/EyeTracking/CalibrationBridge.swift @@ -0,0 +1,36 @@ +// +// CalibrationBridge.swift +// Gaze +// +// Thread-safe calibration access for eye tracking. +// + +import Foundation + +final class CalibrationBridge: @unchecked Sendable { + nonisolated var thresholds: GazeThresholds? { + CalibrationState.shared.thresholds + } + + nonisolated var isComplete: Bool { + CalibrationState.shared.isComplete + } + + nonisolated func submitSample( + leftRatio: Double, + rightRatio: Double, + leftVertical: Double?, + rightVertical: Double?, + faceWidthRatio: Double + ) { + Task { @MainActor in + CalibrationManager.shared.collectSample( + leftRatio: leftRatio, + rightRatio: rightRatio, + leftVertical: leftVertical, + rightVertical: rightVertical, + faceWidthRatio: faceWidthRatio + ) + } + } +} diff --git a/Gaze/Services/EyeTracking/CameraSessionManager.swift b/Gaze/Services/EyeTracking/CameraSessionManager.swift new file mode 100644 index 0000000..252193f --- /dev/null +++ b/Gaze/Services/EyeTracking/CameraSessionManager.swift @@ -0,0 +1,123 @@ +// +// CameraSessionManager.swift +// Gaze +// +// Manages AVCaptureSession lifecycle for eye tracking. +// + +import AVFoundation +import Combine +import Foundation + +protocol CameraSessionDelegate: AnyObject { + nonisolated func cameraSession( + _ manager: CameraSessionManager, + didOutput pixelBuffer: CVPixelBuffer, + imageSize: CGSize + ) +} + +final class CameraSessionManager: NSObject, ObservableObject { + @Published private(set) var isRunning = false + weak var delegate: CameraSessionDelegate? + + private var captureSession: AVCaptureSession? + private var videoOutput: AVCaptureVideoDataOutput? + private let videoDataOutputQueue = DispatchQueue( + label: "com.gaze.videoDataOutput", + qos: .userInitiated + ) + private var _previewLayer: AVCaptureVideoPreviewLayer? + + var previewLayer: AVCaptureVideoPreviewLayer? { + guard let session = captureSession else { + _previewLayer = nil + return nil + } + + if let existing = _previewLayer, existing.session === session { + return existing + } + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + _previewLayer = layer + return layer + } + + @MainActor + func start() async throws { + guard !isRunning else { return } + + let cameraService = CameraAccessService.shared + if !cameraService.isCameraAuthorized { + try await cameraService.requestCameraAccess() + } + + guard cameraService.isCameraAuthorized else { + throw CameraAccessError.accessDenied + } + + try setupCaptureSession() + captureSession?.startRunning() + isRunning = true + } + + @MainActor + func stop() { + captureSession?.stopRunning() + captureSession = nil + videoOutput = nil + _previewLayer = nil + isRunning = false + } + + private func setupCaptureSession() 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 + } +} + +extension CameraSessionManager: AVCaptureVideoDataOutputSampleBufferDelegate { + nonisolated func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + return + } + + let size = CGSize( + width: CVPixelBufferGetWidth(pixelBuffer), + height: CVPixelBufferGetHeight(pixelBuffer) + ) + + delegate?.cameraSession(self, didOutput: pixelBuffer, imageSize: size) + } +} diff --git a/Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift b/Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift new file mode 100644 index 0000000..40f2a67 --- /dev/null +++ b/Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift @@ -0,0 +1,101 @@ +// +// EyeDebugStateAdapter.swift +// Gaze +// +// Debug state storage for eye tracking UI. +// + +import AppKit +import Foundation + +@MainActor +final class EyeDebugStateAdapter { + var leftPupilRatio: Double? + var rightPupilRatio: Double? + var leftVerticalRatio: Double? + var rightVerticalRatio: Double? + var yaw: Double? + var pitch: Double? + var enableDebugLogging: Bool = false { + didSet { + PupilDetector.enableDiagnosticLogging = enableDebugLogging + } + } + + var leftEyeInput: NSImage? + var rightEyeInput: NSImage? + var leftEyeProcessed: NSImage? + var rightEyeProcessed: NSImage? + var leftPupilPosition: PupilPosition? + var rightPupilPosition: PupilPosition? + var leftEyeSize: CGSize? + var rightEyeSize: CGSize? + var leftEyeRegion: EyeRegion? + var rightEyeRegion: EyeRegion? + var imageSize: CGSize? + + var gazeDirection: GazeDirection { + guard let leftH = leftPupilRatio, + let rightH = rightPupilRatio, + let leftV = leftVerticalRatio, + let rightV = rightVerticalRatio else { + return .center + } + + let avgHorizontal = (leftH + rightH) / 2.0 + let avgVertical = (leftV + rightV) / 2.0 + + return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical) + } + + func update(from result: EyeTrackingProcessingResult) { + leftPupilRatio = result.leftPupilRatio + rightPupilRatio = result.rightPupilRatio + leftVerticalRatio = result.leftVerticalRatio + rightVerticalRatio = result.rightVerticalRatio + yaw = result.yaw + pitch = result.pitch + } + + func updateEyeImages(from detector: PupilDetector.Type) { + if let leftInput = detector.debugLeftEyeInput { + leftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height)) + } + if let rightInput = detector.debugRightEyeInput { + rightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height)) + } + if let leftProcessed = detector.debugLeftEyeProcessed { + leftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height)) + } + if let rightProcessed = detector.debugRightEyeProcessed { + rightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height)) + } + leftPupilPosition = detector.debugLeftPupilPosition + rightPupilPosition = detector.debugRightPupilPosition + leftEyeSize = detector.debugLeftEyeSize + rightEyeSize = detector.debugRightEyeSize + leftEyeRegion = detector.debugLeftEyeRegion + rightEyeRegion = detector.debugRightEyeRegion + imageSize = detector.debugImageSize + } + + func clear() { + leftPupilRatio = nil + rightPupilRatio = nil + leftVerticalRatio = nil + rightVerticalRatio = nil + yaw = nil + pitch = nil + leftEyeInput = nil + rightEyeInput = nil + leftEyeProcessed = nil + rightEyeProcessed = nil + leftPupilPosition = nil + rightPupilPosition = nil + leftEyeSize = nil + rightEyeSize = nil + leftEyeRegion = nil + rightEyeRegion = nil + imageSize = nil + } +} diff --git a/Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift b/Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift new file mode 100644 index 0000000..35d0384 --- /dev/null +++ b/Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift @@ -0,0 +1,21 @@ +// +// EyeTrackingProcessingResult.swift +// Gaze +// +// Shared processing result for eye tracking pipeline. +// + +import Foundation + +struct EyeTrackingProcessingResult: Sendable { + let faceDetected: Bool + let isEyesClosed: Bool + let userLookingAtScreen: Bool + let leftPupilRatio: Double? + let rightPupilRatio: Double? + let leftVerticalRatio: Double? + let rightVerticalRatio: Double? + let yaw: Double? + let pitch: Double? + let faceWidthRatio: Double? +} diff --git a/Gaze/Services/EyeTracking/GazeDetector.swift b/Gaze/Services/EyeTracking/GazeDetector.swift new file mode 100644 index 0000000..cac09f9 --- /dev/null +++ b/Gaze/Services/EyeTracking/GazeDetector.swift @@ -0,0 +1,331 @@ +// +// GazeDetector.swift +// Gaze +// +// Gaze detection logic and pupil analysis. +// + +import Foundation +import Vision +import simd + +final class GazeDetector: @unchecked Sendable { + struct GazeResult: Sendable { + let isLookingAway: Bool + let isEyesClosed: Bool + let leftPupilRatio: Double? + let rightPupilRatio: Double? + let leftVerticalRatio: Double? + let rightVerticalRatio: Double? + let yaw: Double? + let pitch: Double? + } + + struct Configuration: Sendable { + let thresholds: GazeThresholds? + let isCalibrationComplete: Bool + let eyeClosedEnabled: Bool + let eyeClosedThreshold: CGFloat + let yawEnabled: Bool + let yawThreshold: Double + let pitchUpEnabled: Bool + let pitchUpThreshold: Double + let pitchDownEnabled: Bool + let pitchDownThreshold: Double + let pixelGazeEnabled: Bool + let pixelGazeMinRatio: Double + let pixelGazeMaxRatio: Double + let boundaryForgivenessMargin: Double + let distanceSensitivity: Double + let defaultReferenceFaceWidth: Double + } + + private let lock = NSLock() + private var configuration: Configuration + + init(configuration: Configuration) { + self.configuration = configuration + } + + func updateConfiguration(_ configuration: Configuration) { + lock.lock() + self.configuration = configuration + lock.unlock() + } + + nonisolated func process( + analysis: VisionPipeline.FaceAnalysis, + pixelBuffer: CVPixelBuffer + ) -> EyeTrackingProcessingResult { + let config: Configuration + lock.lock() + config = configuration + lock.unlock() + + guard analysis.faceDetected, let face = analysis.face else { + return EyeTrackingProcessingResult( + faceDetected: false, + isEyesClosed: false, + userLookingAtScreen: false, + leftPupilRatio: nil, + rightPupilRatio: nil, + leftVerticalRatio: nil, + rightVerticalRatio: nil, + yaw: analysis.debugYaw, + pitch: analysis.debugPitch, + faceWidthRatio: nil + ) + } + + let landmarks = face.landmarks + let yaw = face.yaw?.doubleValue ?? 0.0 + let pitch = face.pitch?.doubleValue ?? 0.0 + + var isEyesClosed = false + if let leftEye = landmarks?.leftEye, let rightEye = landmarks?.rightEye { + isEyesClosed = detectEyesClosed(leftEye: leftEye, rightEye: rightEye, configuration: config) + } + + let gazeResult = detectLookingAway( + face: face, + landmarks: landmarks, + imageSize: analysis.imageSize, + pixelBuffer: pixelBuffer, + configuration: config + ) + + let lookingAway = gazeResult.lookingAway + let userLookingAtScreen = !lookingAway + + return EyeTrackingProcessingResult( + faceDetected: true, + isEyesClosed: isEyesClosed, + userLookingAtScreen: userLookingAtScreen, + leftPupilRatio: gazeResult.leftPupilRatio, + rightPupilRatio: gazeResult.rightPupilRatio, + leftVerticalRatio: gazeResult.leftVerticalRatio, + rightVerticalRatio: gazeResult.rightVerticalRatio, + yaw: gazeResult.yaw ?? yaw, + pitch: gazeResult.pitch ?? pitch, + faceWidthRatio: face.boundingBox.width + ) + } + + private func detectEyesClosed( + leftEye: VNFaceLandmarkRegion2D, + rightEye: VNFaceLandmarkRegion2D, + configuration: Configuration + ) -> Bool { + guard configuration.eyeClosedEnabled else { return false } + guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { return false } + + let leftEyeHeight = calculateEyeHeight(leftEye) + let rightEyeHeight = calculateEyeHeight(rightEye) + let closedThreshold = configuration.eyeClosedThreshold + + 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 struct GazeDetectionResult: Sendable { + var lookingAway: Bool = false + var leftPupilRatio: Double? + var rightPupilRatio: Double? + var leftVerticalRatio: Double? + var rightVerticalRatio: Double? + var yaw: Double? + var pitch: Double? + } + + private func detectLookingAway( + face: VNFaceObservation, + landmarks: VNFaceLandmarks2D?, + imageSize: CGSize, + pixelBuffer: CVPixelBuffer, + configuration: Configuration + ) -> GazeDetectionResult { + var result = GazeDetectionResult() + + let yaw = face.yaw?.doubleValue ?? 0.0 + let pitch = face.pitch?.doubleValue ?? 0.0 + result.yaw = yaw + result.pitch = pitch + + var poseLookingAway = false + + if face.pitch != nil { + if configuration.yawEnabled { + let yawThreshold = configuration.yawThreshold + if abs(yaw) > yawThreshold { + poseLookingAway = true + } + } + + if !poseLookingAway { + var pitchLookingAway = false + + if configuration.pitchUpEnabled && pitch > configuration.pitchUpThreshold { + pitchLookingAway = true + } + + if configuration.pitchDownEnabled && pitch < configuration.pitchDownThreshold { + pitchLookingAway = true + } + + poseLookingAway = pitchLookingAway + } + } + + var eyesLookingAway = false + + if let landmarks, + let leftEye = landmarks.leftEye, + let rightEye = landmarks.rightEye, + configuration.pixelGazeEnabled { + var leftGazeRatio: Double? = nil + var rightGazeRatio: Double? = nil + var leftVerticalRatio: Double? = nil + var rightVerticalRatio: Double? = nil + + if let leftResult = PupilDetector.detectPupil( + in: pixelBuffer, + eyeLandmarks: leftEye, + faceBoundingBox: face.boundingBox, + imageSize: imageSize, + side: 0 + ) { + leftGazeRatio = calculateGazeRatio( + pupilPosition: leftResult.pupilPosition, + eyeRegion: leftResult.eyeRegion + ) + leftVerticalRatio = calculateVerticalRatio( + pupilPosition: leftResult.pupilPosition, + eyeRegion: leftResult.eyeRegion + ) + } + + if let rightResult = PupilDetector.detectPupil( + in: pixelBuffer, + eyeLandmarks: rightEye, + faceBoundingBox: face.boundingBox, + imageSize: imageSize, + side: 1 + ) { + rightGazeRatio = calculateGazeRatio( + pupilPosition: rightResult.pupilPosition, + eyeRegion: rightResult.eyeRegion + ) + rightVerticalRatio = calculateVerticalRatio( + pupilPosition: rightResult.pupilPosition, + eyeRegion: rightResult.eyeRegion + ) + } + + result.leftPupilRatio = leftGazeRatio + result.rightPupilRatio = rightGazeRatio + result.leftVerticalRatio = leftVerticalRatio + result.rightVerticalRatio = rightVerticalRatio + + if let leftRatio = leftGazeRatio, + let rightRatio = rightGazeRatio { + let avgH = (leftRatio + rightRatio) / 2.0 + let avgV = (leftVerticalRatio != nil && rightVerticalRatio != nil) + ? (leftVerticalRatio! + rightVerticalRatio!) / 2.0 + : 0.5 + + if configuration.isCalibrationComplete, + let thresholds = configuration.thresholds { + let currentFaceWidth = face.boundingBox.width + let refFaceWidth = thresholds.referenceFaceWidth + + var distanceScale = 1.0 + if refFaceWidth > 0 && currentFaceWidth > 0 { + let rawScale = refFaceWidth / currentFaceWidth + distanceScale = 1.0 + (rawScale - 1.0) * configuration.distanceSensitivity + distanceScale = max(0.5, min(2.0, distanceScale)) + } + + let centerH = (thresholds.screenLeftBound + thresholds.screenRightBound) / 2.0 + let centerV = (thresholds.screenTopBound + thresholds.screenBottomBound) / 2.0 + + let deltaH = (avgH - centerH) * distanceScale + let deltaV = (avgV - centerV) * distanceScale + + let normalizedH = centerH + deltaH + let normalizedV = centerV + deltaV + + let margin = configuration.boundaryForgivenessMargin + let isLookingLeft = normalizedH > (thresholds.screenLeftBound + margin) + let isLookingRight = normalizedH < (thresholds.screenRightBound - margin) + let isLookingUp = normalizedV < (thresholds.screenTopBound - margin) + let isLookingDown = normalizedV > (thresholds.screenBottomBound + margin) + + eyesLookingAway = isLookingLeft || isLookingRight || isLookingUp || isLookingDown + } else { + let currentFaceWidth = face.boundingBox.width + let refFaceWidth = configuration.defaultReferenceFaceWidth + + var distanceScale = 1.0 + if refFaceWidth > 0 && currentFaceWidth > 0 { + let rawScale = refFaceWidth / currentFaceWidth + distanceScale = 1.0 + (rawScale - 1.0) * configuration.distanceSensitivity + distanceScale = max(0.5, min(2.0, distanceScale)) + } + + let centerH = (configuration.pixelGazeMinRatio + configuration.pixelGazeMaxRatio) / 2.0 + let normalizedH = centerH + (avgH - centerH) * distanceScale + + let lookingRight = normalizedH <= configuration.pixelGazeMinRatio + let lookingLeft = normalizedH >= configuration.pixelGazeMaxRatio + eyesLookingAway = lookingRight || lookingLeft + } + } + } + + result.lookingAway = poseLookingAway || eyesLookingAway + return result + } + + private func calculateGazeRatio( + pupilPosition: PupilPosition, + eyeRegion: EyeRegion + ) -> Double { + let pupilX = Double(pupilPosition.x) + let eyeCenterX = Double(eyeRegion.center.x) + let denominator = (eyeCenterX * 2.0 - 10.0) + + guard denominator > 0 else { + let eyeLeft = Double(eyeRegion.frame.minX) + let eyeRight = Double(eyeRegion.frame.maxX) + let eyeWidth = eyeRight - eyeLeft + guard eyeWidth > 0 else { return 0.5 } + return (pupilX - eyeLeft) / eyeWidth + } + + let ratio = pupilX / denominator + return max(0.0, min(1.0, ratio)) + } + + private func calculateVerticalRatio( + pupilPosition: PupilPosition, + eyeRegion: EyeRegion + ) -> Double { + let pupilX = Double(pupilPosition.x) + let eyeWidth = Double(eyeRegion.frame.width) + + guard eyeWidth > 0 else { return 0.5 } + + let ratio = pupilX / eyeWidth + return max(0.0, min(1.0, ratio)) + } +} diff --git a/Gaze/Services/EyeTracking/VisionPipeline.swift b/Gaze/Services/EyeTracking/VisionPipeline.swift new file mode 100644 index 0000000..3d5a8fe --- /dev/null +++ b/Gaze/Services/EyeTracking/VisionPipeline.swift @@ -0,0 +1,67 @@ +// +// VisionPipeline.swift +// Gaze +// +// Vision processing pipeline for face detection. +// + +import Foundation +import Vision + +final class VisionPipeline: @unchecked Sendable { + struct FaceAnalysis: Sendable { + let faceDetected: Bool + let face: VNFaceObservation? + let imageSize: CGSize + let debugYaw: Double? + let debugPitch: Double? + } + + nonisolated func analyze( + pixelBuffer: CVPixelBuffer, + imageSize: CGSize + ) -> FaceAnalysis { + let request = VNDetectFaceLandmarksRequest() + request.revision = VNDetectFaceLandmarksRequestRevision3 + + if #available(macOS 14.0, *) { + request.constellation = .constellation76Points + } + + let handler = VNImageRequestHandler( + cvPixelBuffer: pixelBuffer, + orientation: .upMirrored, + options: [:] + ) + + do { + try handler.perform([request]) + } catch { + return FaceAnalysis( + faceDetected: false, + face: nil, + imageSize: imageSize, + debugYaw: nil, + debugPitch: nil + ) + } + + guard let face = (request.results as? [VNFaceObservation])?.first else { + return FaceAnalysis( + faceDetected: false, + face: nil, + imageSize: imageSize, + debugYaw: nil, + debugPitch: nil + ) + } + + return FaceAnalysis( + faceDetected: true, + face: face, + imageSize: imageSize, + debugYaw: face.yaw?.doubleValue, + debugPitch: face.pitch?.doubleValue + ) + } +} diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index 12c990f..d1a99a2 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -5,11 +5,10 @@ // Created by Mike Freno on 1/13/26. // +import AppKit import AVFoundation import Combine -import Vision -import simd -import AppKit +import Foundation @MainActor class EyeTrackingService: NSObject, ObservableObject { @@ -19,8 +18,6 @@ class EyeTrackingService: NSObject, ObservableObject { @Published var isEyesClosed = false @Published var userLookingAtScreen = true @Published var faceDetected = false - - // Debug properties for UI display @Published var debugLeftPupilRatio: Double? @Published var debugRightPupilRatio: Double? @Published var debugLeftVerticalRatio: Double? @@ -29,12 +26,9 @@ class EyeTrackingService: NSObject, ObservableObject { @Published var debugPitch: Double? @Published var enableDebugLogging: Bool = false { didSet { - // Sync with PupilDetector's diagnostic logging - PupilDetector.enableDiagnosticLogging = enableDebugLogging + debugAdapter.enableDebugLogging = enableDebugLogging } } - - // Debug eye images for UI display @Published var debugLeftEyeInput: NSImage? @Published var debugRightEyeInput: NSImage? @Published var debugLeftEyeProcessed: NSImage? @@ -43,13 +37,20 @@ class EyeTrackingService: NSObject, ObservableObject { @Published var debugRightPupilPosition: PupilPosition? @Published var debugLeftEyeSize: CGSize? @Published var debugRightEyeSize: CGSize? - - // Eye region positions for video overlay @Published var debugLeftEyeRegion: EyeRegion? @Published var debugRightEyeRegion: EyeRegion? @Published var debugImageSize: CGSize? - - // Computed gaze direction for UI overlay + + private let cameraManager = CameraSessionManager() + nonisolated(unsafe) private let visionPipeline = VisionPipeline() + private let debugAdapter = EyeDebugStateAdapter() + private let calibrationBridge = CalibrationBridge() + nonisolated(unsafe) private let gazeDetector: GazeDetector + + var previewLayer: AVCaptureVideoPreviewLayer? { + cameraManager.previewLayer + } + var gazeDirection: GazeDirection { guard let leftH = debugLeftPupilRatio, let rightH = debugRightPupilRatio, @@ -57,83 +58,39 @@ class EyeTrackingService: NSObject, ObservableObject { let rightV = debugRightVerticalRatio else { return .center } - + let avgHorizontal = (leftH + rightH) / 2.0 let avgVertical = (leftV + rightV) / 2.0 - + return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical) } - + var isInFrame: Bool { faceDetected } - // Throttle for debug logging - private var lastDebugLogTime: Date = .distantPast - - private var captureSession: AVCaptureSession? - private var videoOutput: AVCaptureVideoDataOutput? - private let videoDataOutputQueue = DispatchQueue( - label: "com.gaze.videoDataOutput", qos: .userInitiated) - private var _previewLayer: AVCaptureVideoPreviewLayer? - - var previewLayer: AVCaptureVideoPreviewLayer? { - guard let session = captureSession else { - _previewLayer = nil - return nil - } - - // Reuse existing layer if session hasn't changed - if let existing = _previewLayer, existing.session === session { - return existing - } - - // Create new layer only when session changes - let layer = AVCaptureVideoPreviewLayer(session: session) - layer.videoGravity = .resizeAspectFill - _previewLayer = layer - return layer - } - private override init() { + let configuration = GazeDetector.Configuration( + thresholds: CalibrationState.shared.thresholds, + isCalibrationComplete: CalibrationState.shared.isComplete, + eyeClosedEnabled: EyeTrackingConstants.eyeClosedEnabled, + eyeClosedThreshold: EyeTrackingConstants.eyeClosedThreshold, + yawEnabled: EyeTrackingConstants.yawEnabled, + yawThreshold: EyeTrackingConstants.yawThreshold, + pitchUpEnabled: EyeTrackingConstants.pitchUpEnabled, + pitchUpThreshold: EyeTrackingConstants.pitchUpThreshold, + pitchDownEnabled: EyeTrackingConstants.pitchDownEnabled, + pitchDownThreshold: EyeTrackingConstants.pitchDownThreshold, + pixelGazeEnabled: EyeTrackingConstants.pixelGazeEnabled, + pixelGazeMinRatio: EyeTrackingConstants.pixelGazeMinRatio, + pixelGazeMaxRatio: EyeTrackingConstants.pixelGazeMaxRatio, + boundaryForgivenessMargin: EyeTrackingConstants.boundaryForgivenessMargin, + distanceSensitivity: EyeTrackingConstants.distanceSensitivity, + defaultReferenceFaceWidth: EyeTrackingConstants.defaultReferenceFaceWidth + ) + self.gazeDetector = GazeDetector(configuration: configuration) super.init() - } - - // MARK: - Processing Result - - /// Result struct for off-main-thread processing - private struct ProcessingResult: Sendable { - var faceDetected: Bool = false - var isEyesClosed: Bool = false - var userLookingAtScreen: Bool = true - var debugLeftPupilRatio: Double? - var debugRightPupilRatio: Double? - var debugLeftVerticalRatio: Double? - var debugRightVerticalRatio: Double? - var debugYaw: Double? - var debugPitch: Double? - - nonisolated init( - faceDetected: Bool = false, - isEyesClosed: Bool = false, - userLookingAtScreen: Bool = true, - debugLeftPupilRatio: Double? = nil, - debugRightPupilRatio: Double? = nil, - debugLeftVerticalRatio: Double? = nil, - debugRightVerticalRatio: Double? = nil, - debugYaw: Double? = nil, - debugPitch: Double? = nil - ) { - self.faceDetected = faceDetected - self.isEyesClosed = isEyesClosed - self.userLookingAtScreen = userLookingAtScreen - self.debugLeftPupilRatio = debugLeftPupilRatio - self.debugRightPupilRatio = debugRightPupilRatio - self.debugLeftVerticalRatio = debugLeftVerticalRatio - self.debugRightVerticalRatio = debugRightVerticalRatio - self.debugYaw = debugYaw - self.debugPitch = debugPitch - } + cameraManager.delegate = self } func startEyeTracking() async throws { @@ -143,853 +100,98 @@ class EyeTrackingService: NSObject, ObservableObject { 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() + try await cameraManager.start() isEyeTrackingActive = true print("โœ“ Eye tracking active") } func stopEyeTracking() { - captureSession?.stopRunning() - captureSession = nil - videoOutput = nil - _previewLayer = nil + cameraManager.stop() isEyeTrackingActive = false isEyesClosed = false userLookingAtScreen = true faceDetected = false + debugAdapter.clear() + syncDebugState() } - 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 syncDebugState() { + debugLeftPupilRatio = debugAdapter.leftPupilRatio + debugRightPupilRatio = debugAdapter.rightPupilRatio + debugLeftVerticalRatio = debugAdapter.leftVerticalRatio + debugRightVerticalRatio = debugAdapter.rightVerticalRatio + debugYaw = debugAdapter.yaw + debugPitch = debugAdapter.pitch + debugLeftEyeInput = debugAdapter.leftEyeInput + debugRightEyeInput = debugAdapter.rightEyeInput + debugLeftEyeProcessed = debugAdapter.leftEyeProcessed + debugRightEyeProcessed = debugAdapter.rightEyeProcessed + debugLeftPupilPosition = debugAdapter.leftPupilPosition + debugRightPupilPosition = debugAdapter.rightPupilPosition + debugLeftEyeSize = debugAdapter.leftEyeSize + debugRightEyeSize = debugAdapter.rightEyeSize + debugLeftEyeRegion = debugAdapter.leftEyeRegion + debugRightEyeRegion = debugAdapter.rightEyeRegion + debugImageSize = debugAdapter.imageSize } - private func processFaceObservations( - _ observations: [VNFaceObservation]?, imageSize: CGSize, pixelBuffer: CVPixelBuffer? = nil - ) { - guard let observations = observations, !observations.isEmpty else { - faceDetected = false - userLookingAtScreen = false - return - } - - faceDetected = true - let face = observations.first! - - if enableDebugLogging { - print("๐Ÿ‘๏ธ Face observation - boundingBox: \(face.boundingBox)") - print( - "๐Ÿ‘๏ธ Yaw: \(face.yaw?.doubleValue ?? 999), Pitch: \(face.pitch?.doubleValue ?? 999), Roll: \(face.roll?.doubleValue ?? 999)" - ) - } - - guard let landmarks = face.landmarks else { - if enableDebugLogging { - print("๐Ÿ‘๏ธ No landmarks available") - } - return - } - - if enableDebugLogging { - print( - "๐Ÿ‘๏ธ Landmarks - leftEye: \(landmarks.leftEye != nil), rightEye: \(landmarks.rightEye != nil), leftPupil: \(landmarks.leftPupil != nil), rightPupil: \(landmarks.rightPupil != nil)" - ) - } - - // Check eye closure - if let leftEye = landmarks.leftEye, - let rightEye = landmarks.rightEye - { - let eyesClosed = detectEyesClosed( - leftEye: leftEye, rightEye: rightEye, shouldLog: false) - self.isEyesClosed = eyesClosed - } - - // Check gaze direction - let lookingAway = detectLookingAway( - face: face, - landmarks: landmarks, - imageSize: imageSize, - pixelBuffer: pixelBuffer, - shouldLog: enableDebugLogging + nonisolated private func updateGazeConfiguration() { + let configuration = GazeDetector.Configuration( + thresholds: calibrationBridge.thresholds, + isCalibrationComplete: calibrationBridge.isComplete, + eyeClosedEnabled: EyeTrackingConstants.eyeClosedEnabled, + eyeClosedThreshold: EyeTrackingConstants.eyeClosedThreshold, + yawEnabled: EyeTrackingConstants.yawEnabled, + yawThreshold: EyeTrackingConstants.yawThreshold, + pitchUpEnabled: EyeTrackingConstants.pitchUpEnabled, + pitchUpThreshold: EyeTrackingConstants.pitchUpThreshold, + pitchDownEnabled: EyeTrackingConstants.pitchDownEnabled, + pitchDownThreshold: EyeTrackingConstants.pitchDownThreshold, + pixelGazeEnabled: EyeTrackingConstants.pixelGazeEnabled, + pixelGazeMinRatio: EyeTrackingConstants.pixelGazeMinRatio, + pixelGazeMaxRatio: EyeTrackingConstants.pixelGazeMaxRatio, + boundaryForgivenessMargin: EyeTrackingConstants.boundaryForgivenessMargin, + distanceSensitivity: EyeTrackingConstants.distanceSensitivity, + defaultReferenceFaceWidth: EyeTrackingConstants.defaultReferenceFaceWidth ) - userLookingAtScreen = !lookingAway + gazeDetector.updateConfiguration(configuration) } - - /// Non-isolated synchronous version for off-main-thread processing - /// Returns a result struct instead of updating @Published properties directly - nonisolated private func processFaceObservationsSync( - _ observations: [VNFaceObservation]?, - imageSize: CGSize, - pixelBuffer: CVPixelBuffer? = nil - ) -> ProcessingResult { - var result = ProcessingResult() - - guard let observations = observations, !observations.isEmpty else { - result.faceDetected = false - result.userLookingAtScreen = false - return result - } - - result.faceDetected = true - let face = observations.first! - - // Always extract yaw/pitch from face, even if landmarks aren't available - result.debugYaw = face.yaw?.doubleValue ?? 0.0 - result.debugPitch = face.pitch?.doubleValue ?? 0.0 - - guard let landmarks = face.landmarks else { - return result - } - - // Check eye closure - if let leftEye = landmarks.leftEye, - let rightEye = landmarks.rightEye - { - result.isEyesClosed = detectEyesClosedSync( - leftEye: leftEye, rightEye: rightEye) - } - - // Check gaze direction - let gazeResult = detectLookingAwaySync( - face: face, - landmarks: landmarks, - imageSize: imageSize, - pixelBuffer: pixelBuffer - ) - - result.userLookingAtScreen = !gazeResult.lookingAway - result.debugLeftPupilRatio = gazeResult.leftPupilRatio - result.debugRightPupilRatio = gazeResult.rightPupilRatio - result.debugLeftVerticalRatio = gazeResult.leftVerticalRatio - result.debugRightVerticalRatio = gazeResult.rightVerticalRatio - result.debugYaw = gazeResult.yaw - result.debugPitch = gazeResult.pitch - - return result - } - - /// Non-isolated eye closure detection - nonisolated private func detectEyesClosedSync( - leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D - ) -> Bool { - guard EyeTrackingConstants.eyeClosedEnabled else { - return false - } - - guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { - return false - } - - let leftEyeHeight = calculateEyeHeightSync(leftEye) - let rightEyeHeight = calculateEyeHeightSync(rightEye) - - let closedThreshold = EyeTrackingConstants.eyeClosedThreshold - - return leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold - } - - nonisolated private func calculateEyeHeightSync(_ 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) - } - - /// Non-isolated gaze detection result - private struct GazeResult: Sendable { - var lookingAway: Bool = false - var leftPupilRatio: Double? - var rightPupilRatio: Double? - var leftVerticalRatio: Double? - var rightVerticalRatio: Double? - var yaw: Double? - var pitch: Double? - - nonisolated init( - lookingAway: Bool = false, - leftPupilRatio: Double? = nil, - rightPupilRatio: Double? = nil, - leftVerticalRatio: Double? = nil, - rightVerticalRatio: Double? = nil, - yaw: Double? = nil, - pitch: Double? = nil - ) { - self.lookingAway = lookingAway - self.leftPupilRatio = leftPupilRatio - self.rightPupilRatio = rightPupilRatio - self.leftVerticalRatio = leftVerticalRatio - self.rightVerticalRatio = rightVerticalRatio - self.yaw = yaw - self.pitch = pitch - } - } - - /// Non-isolated gaze direction detection - nonisolated private func detectLookingAwaySync( - face: VNFaceObservation, - landmarks: VNFaceLandmarks2D, - imageSize: CGSize, - pixelBuffer: CVPixelBuffer? - ) -> GazeResult { - var result = GazeResult() - -// 1. Face Pose Check (Yaw & Pitch) - let yaw = face.yaw?.doubleValue ?? 0.0 - let pitch = face.pitch?.doubleValue ?? 0.0 - - result.yaw = yaw - result.pitch = pitch - - var poseLookingAway = false - - if face.pitch != nil { - if EyeTrackingConstants.yawEnabled { - let yawThreshold = EyeTrackingConstants.yawThreshold - if abs(yaw) > yawThreshold { - poseLookingAway = true - } - } - - if !poseLookingAway { - var pitchLookingAway = false - - if EyeTrackingConstants.pitchUpEnabled && pitch > EyeTrackingConstants.pitchUpThreshold { - pitchLookingAway = true - } - - if EyeTrackingConstants.pitchDownEnabled && pitch < EyeTrackingConstants.pitchDownThreshold { - pitchLookingAway = true - } - - poseLookingAway = pitchLookingAway - } - } - - // 2. Eye Gaze Check (Pixel-Based Pupil Detection) - var eyesLookingAway = false - - if let pixelBuffer = pixelBuffer, - let leftEye = landmarks.leftEye, - let rightEye = landmarks.rightEye, - EyeTrackingConstants.pixelGazeEnabled - { - var leftGazeRatio: Double? = nil - var rightGazeRatio: Double? = nil - var leftVerticalRatio: Double? = nil - var rightVerticalRatio: Double? = nil - - // Detect left pupil (side = 0) - if let leftResult = PupilDetector.detectPupil( - in: pixelBuffer, - eyeLandmarks: leftEye, - faceBoundingBox: face.boundingBox, - imageSize: imageSize, - side: 0 - ) { - leftGazeRatio = calculateGazeRatioSync( - pupilPosition: leftResult.pupilPosition, - eyeRegion: leftResult.eyeRegion - ) - leftVerticalRatio = calculateVerticalRatioSync( - pupilPosition: leftResult.pupilPosition, - eyeRegion: leftResult.eyeRegion - ) - } - - // Detect right pupil (side = 1) - if let rightResult = PupilDetector.detectPupil( - in: pixelBuffer, - eyeLandmarks: rightEye, - faceBoundingBox: face.boundingBox, - imageSize: imageSize, - side: 1 - ) { - rightGazeRatio = calculateGazeRatioSync( - pupilPosition: rightResult.pupilPosition, - eyeRegion: rightResult.eyeRegion - ) - rightVerticalRatio = calculateVerticalRatioSync( - pupilPosition: rightResult.pupilPosition, - eyeRegion: rightResult.eyeRegion - ) - } - - result.leftPupilRatio = leftGazeRatio - result.rightPupilRatio = rightGazeRatio - result.leftVerticalRatio = leftVerticalRatio - result.rightVerticalRatio = rightVerticalRatio - - // Connect to CalibrationManager on main thread - if let leftRatio = leftGazeRatio, - let rightRatio = rightGazeRatio - { - let faceWidth = face.boundingBox.width - - Task { @MainActor in - if CalibrationManager.shared.isCalibrating { - CalibrationManager.shared.collectSample( - leftRatio: leftRatio, - rightRatio: rightRatio, - leftVertical: leftVerticalRatio, - rightVertical: rightVerticalRatio, - faceWidthRatio: faceWidth - ) - } - } - - let avgH = (leftRatio + rightRatio) / 2.0 - // Use 0.5 as default for vertical if not available - let avgV = (leftVerticalRatio != nil && rightVerticalRatio != nil) - ? (leftVerticalRatio! + rightVerticalRatio!) / 2.0 - : 0.5 - - // Use Calibrated Thresholds from thread-safe state - if let thresholds = CalibrationState.shared.thresholds, - CalibrationState.shared.isComplete { - - // 1. Distance Scaling using face width as proxy - // When user is farther from screen, face appears smaller and eye movements - // (in ratio terms) compress toward center. We scale to compensate. - let currentFaceWidth = face.boundingBox.width - let refFaceWidth = thresholds.referenceFaceWidth - - var distanceScale = 1.0 - if refFaceWidth > 0 && currentFaceWidth > 0 { - // ratio > 1 means user is farther than calibration distance - // ratio < 1 means user is closer than calibration distance - let rawScale = refFaceWidth / currentFaceWidth - // Apply sensitivity factor and clamp to reasonable range - distanceScale = 1.0 + (rawScale - 1.0) * EyeTrackingConstants.distanceSensitivity - distanceScale = max(0.5, min(2.0, distanceScale)) // Clamp to 0.5x - 2x - } - - // 2. Calculate calibrated center point - let centerH = (thresholds.screenLeftBound + thresholds.screenRightBound) / 2.0 - let centerV = (thresholds.screenTopBound + thresholds.screenBottomBound) / 2.0 - - // 3. Normalize gaze relative to center, scaled for distance - // When farther away, eye movements are smaller, so we amplify them - let deltaH = (avgH - centerH) * distanceScale - let deltaV = (avgV - centerV) * distanceScale - - let normalizedH = centerH + deltaH - let normalizedV = centerV + deltaV - - // 4. Boundary Check - compare against screen bounds - // Looking away = gaze is beyond the calibrated screen edges - let margin = EyeTrackingConstants.boundaryForgivenessMargin - - let isLookingLeft = normalizedH > (thresholds.screenLeftBound + margin) - let isLookingRight = normalizedH < (thresholds.screenRightBound - margin) - let isLookingUp = normalizedV < (thresholds.screenTopBound - margin) - let isLookingDown = normalizedV > (thresholds.screenBottomBound + margin) - - eyesLookingAway = isLookingLeft || isLookingRight || isLookingUp || isLookingDown - - } else { - // Fallback to default constants (no calibration) - // Still apply distance scaling using default reference - let currentFaceWidth = face.boundingBox.width - let refFaceWidth = EyeTrackingConstants.defaultReferenceFaceWidth - - var distanceScale = 1.0 - if refFaceWidth > 0 && currentFaceWidth > 0 { - let rawScale = refFaceWidth / currentFaceWidth - distanceScale = 1.0 + (rawScale - 1.0) * EyeTrackingConstants.distanceSensitivity - distanceScale = max(0.5, min(2.0, distanceScale)) - } - - // Center is assumed at midpoint of the thresholds - let centerH = (EyeTrackingConstants.pixelGazeMinRatio + EyeTrackingConstants.pixelGazeMaxRatio) / 2.0 - let normalizedH = centerH + (avgH - centerH) * distanceScale - - let lookingRight = normalizedH <= EyeTrackingConstants.pixelGazeMinRatio - let lookingLeft = normalizedH >= EyeTrackingConstants.pixelGazeMaxRatio - eyesLookingAway = lookingRight || lookingLeft - } - } - } - - result.lookingAway = poseLookingAway || eyesLookingAway - return result - } - - /// Non-isolated horizontal gaze ratio calculation - /// pupilPosition.y controls horizontal gaze (left-right) due to image orientation - /// Returns 0.0 for left edge, 1.0 for right edge, 0.5 for center - nonisolated private func calculateGazeRatioSync( - pupilPosition: PupilPosition, eyeRegion: EyeRegion - ) -> Double { - let pupilY = Double(pupilPosition.y) - let eyeHeight = Double(eyeRegion.frame.height) - - guard eyeHeight > 0 else { return 0.5 } - - let ratio = pupilY / eyeHeight - return max(0.0, min(1.0, ratio)) - } - - /// Non-isolated vertical gaze ratio calculation - /// pupilPosition.x controls vertical gaze (up-down) due to image orientation - /// Returns 0.0 for top edge (looking up), 1.0 for bottom edge (looking down), 0.5 for center - nonisolated private func calculateVerticalRatioSync( - pupilPosition: PupilPosition, eyeRegion: EyeRegion - ) -> Double { - let pupilX = Double(pupilPosition.x) - let eyeWidth = Double(eyeRegion.frame.width) - - guard eyeWidth > 0 else { return 0.5 } - - let ratio = pupilX / eyeWidth - return max(0.0, min(1.0, ratio)) - } - - private func detectEyesClosed( - leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D, shouldLog: Bool - ) -> Bool { - // If eye closure detection is disabled, always return false (eyes not closed) - guard EyeTrackingConstants.eyeClosedEnabled else { - return false - } - - guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { - return false - } - - let leftEyeHeight = calculateEyeHeight(leftEye, shouldLog: shouldLog) - let rightEyeHeight = calculateEyeHeight(rightEye, shouldLog: shouldLog) - - let closedThreshold = EyeTrackingConstants.eyeClosedThreshold - - let isClosed = leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold - - return isClosed - } - - private func calculateEyeHeight(_ eye: VNFaceLandmarkRegion2D, shouldLog: Bool) -> 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 - - let height = abs(maxY - minY) - - return height - } - - private func detectLookingAway( - face: VNFaceObservation, landmarks: VNFaceLandmarks2D, imageSize: CGSize, - pixelBuffer: CVPixelBuffer?, shouldLog: Bool - ) -> Bool { - // 1. Face Pose Check (Yaw & Pitch) - let yaw = face.yaw?.doubleValue ?? 0.0 - let pitch = face.pitch?.doubleValue ?? 0.0 - let roll = face.roll?.doubleValue ?? 0.0 - - // Debug logging - if shouldLog { - print("๐Ÿ‘๏ธ Face Pose - Yaw: \(yaw), Pitch: \(pitch), Roll: \(roll)") - print( - "๐Ÿ‘๏ธ Face available data - hasYaw: \(face.yaw != nil), hasPitch: \(face.pitch != nil), hasRoll: \(face.roll != nil)" - ) - } - - // Update debug values - Task { @MainActor in - debugYaw = yaw - debugPitch = pitch - } - - var poseLookingAway = false - - // Only use yaw/pitch if they're actually available and enabled - // Note: Vision Framework on macOS often doesn't provide reliable pitch data - if face.pitch != nil { - // Check yaw if enabled - if EyeTrackingConstants.yawEnabled { - let yawThreshold = EyeTrackingConstants.yawThreshold - if abs(yaw) > yawThreshold { - poseLookingAway = true - } - } - - // Check pitch if either threshold is enabled - if !poseLookingAway { - var pitchLookingAway = false - - if EyeTrackingConstants.pitchUpEnabled - && pitch > EyeTrackingConstants.pitchUpThreshold - { - pitchLookingAway = true - } - - if EyeTrackingConstants.pitchDownEnabled - && pitch < EyeTrackingConstants.pitchDownThreshold - { - pitchLookingAway = true - } - - poseLookingAway = pitchLookingAway - } - } - - // 2. Eye Gaze Check (Pixel-Based Pupil Detection) - var eyesLookingAway = false - - if let pixelBuffer = pixelBuffer, - let leftEye = landmarks.leftEye, - let rightEye = landmarks.rightEye, - EyeTrackingConstants.pixelGazeEnabled - { - var leftGazeRatio: Double? = nil - var rightGazeRatio: Double? = nil - var leftVerticalRatio: Double? = nil - var rightVerticalRatio: Double? = nil - - // Detect left pupil (side = 0) - if let leftResult = PupilDetector.detectPupil( - in: pixelBuffer, - eyeLandmarks: leftEye, - faceBoundingBox: face.boundingBox, - imageSize: imageSize, - side: 0 - ) { - leftGazeRatio = calculateGazeRatio( - pupilPosition: leftResult.pupilPosition, - eyeRegion: leftResult.eyeRegion - ) - leftVerticalRatio = calculateVerticalRatioSync( - pupilPosition: leftResult.pupilPosition, - eyeRegion: leftResult.eyeRegion - ) - } - - // Detect right pupil (side = 1) - if let rightResult = PupilDetector.detectPupil( - in: pixelBuffer, - eyeLandmarks: rightEye, - faceBoundingBox: face.boundingBox, - imageSize: imageSize, - side: 1 - ) { - rightGazeRatio = calculateGazeRatio( - pupilPosition: rightResult.pupilPosition, - eyeRegion: rightResult.eyeRegion - ) - rightVerticalRatio = calculateVerticalRatioSync( - pupilPosition: rightResult.pupilPosition, - eyeRegion: rightResult.eyeRegion - ) - } - - // CRITICAL: Connect to CalibrationManager - if CalibrationManager.shared.isCalibrating, - let leftRatio = leftGazeRatio, - let rightRatio = rightGazeRatio - { - // Calculate face width ratio for distance estimation - let faceWidthRatio = face.boundingBox.width - - CalibrationManager.shared.collectSample( - leftRatio: leftRatio, - rightRatio: rightRatio, - leftVertical: leftVerticalRatio, - rightVertical: rightVerticalRatio, - faceWidthRatio: faceWidthRatio - ) - } - - // Determine looking away using calibrated thresholds - if let leftRatio = leftGazeRatio, let rightRatio = rightGazeRatio { - let avgH = (leftRatio + rightRatio) / 2.0 - // Use 0.5 as default for vertical if not available (though it should be) - let avgV = (leftVerticalRatio != nil && rightVerticalRatio != nil) - ? (leftVerticalRatio! + rightVerticalRatio!) / 2.0 - : 0.5 - - // Use Calibrated Thresholds if available - // Use thread-safe state instead of accessing CalibrationManager.shared (MainActor) - if let thresholds = CalibrationState.shared.thresholds, - CalibrationState.shared.isComplete { - - // 1. Distance Scaling - // If current face is SMALLER than reference, user is FURTHER away. - // Eyes move LESS for same screen angle. We need to SCALE UP the deviation. - let currentFaceWidth = face.boundingBox.width - let refFaceWidth = thresholds.referenceFaceWidth - - var distanceScale = 1.0 - if refFaceWidth > 0 && currentFaceWidth > 0 { - // Simple linear scaling: scale = ref / current - // e.g. Ref=0.5, Current=0.25 (further) -> Scale=2.0 - distanceScale = refFaceWidth / currentFaceWidth - - // Apply sensitivity tuning - distanceScale = 1.0 + (distanceScale - 1.0) * EyeTrackingConstants.distanceSensitivity - } - - // 2. Normalize Gaze (Center Relative) - // We assume ~0.5 is center. We scale the delta from 0.5. - // Note: This is an approximation. A better way uses the calibrated center. - let centerH = (thresholds.screenLeftBound + thresholds.screenRightBound) / 2.0 - let centerV = (thresholds.screenTopBound + thresholds.screenBottomBound) / 2.0 - - let deltaH = (avgH - centerH) * distanceScale - let deltaV = (avgV - centerV) * distanceScale - - let normalizedH = centerH + deltaH - let normalizedV = centerV + deltaV - - // 3. Boundary Check with Margin - // "Forgiveness" expands the safe zone (screen bounds). - // If you are IN the margin, you are considered ON SCREEN (Safe). - // Looking Away means passing the (Bound + Margin). - - let margin = EyeTrackingConstants.boundaryForgivenessMargin - - // Check Left (Higher Ratio) - // Screen Left is e.g. 0.7. Looking Left > 0.7. - // To look away, must exceed (0.7 + margin). - let isLookingLeft = normalizedH > (thresholds.screenLeftBound + margin) - - // Check Right (Lower Ratio) - // Screen Right is e.g. 0.3. Looking Right < 0.3. - // To look away, must be less than (0.3 - margin). - let isLookingRight = normalizedH < (thresholds.screenRightBound - margin) - - // Check Up (Lower Ratio, usually) - let isLookingUp = normalizedV < (thresholds.screenTopBound - margin) - - // Check Down (Higher Ratio, usually) - let isLookingDown = normalizedV > (thresholds.screenBottomBound + margin) - - eyesLookingAway = isLookingLeft || isLookingRight || isLookingUp || isLookingDown - - if shouldLog { - print("๐Ÿ‘๏ธ CALIBRATED GAZE: AvgH=\(String(format: "%.2f", avgH)) AvgV=\(String(format: "%.2f", avgV)) DistScale=\(String(format: "%.2f", distanceScale))") - print(" NormH=\(String(format: "%.2f", normalizedH)) NormV=\(String(format: "%.2f", normalizedV)) Away=\(eyesLookingAway)") - print(" Bounds: H[\(String(format: "%.2f", thresholds.screenRightBound))-\(String(format: "%.2f", thresholds.screenLeftBound))] V[\(String(format: "%.2f", thresholds.screenTopBound))-\(String(format: "%.2f", thresholds.screenBottomBound))]") - } - - } else { - // Fallback to default constants - let lookingRight = avgH <= EyeTrackingConstants.pixelGazeMinRatio - let lookingLeft = avgH >= EyeTrackingConstants.pixelGazeMaxRatio - eyesLookingAway = lookingRight || lookingLeft - } - - // Update debug values - Task { @MainActor in - debugLeftPupilRatio = leftGazeRatio - debugRightPupilRatio = rightGazeRatio - debugLeftVerticalRatio = leftVerticalRatio - debugRightVerticalRatio = rightVerticalRatio - } - - if shouldLog && !CalibrationState.shared.isComplete { - print( - "๐Ÿ‘๏ธ RAW GAZE: L=\(String(format: "%.3f", leftRatio)) R=\(String(format: "%.3f", rightRatio)) Avg=\(String(format: "%.3f", avgH)) Away=\(eyesLookingAway)" - ) - } - } else { - if shouldLog { - print("โš ๏ธ Pixel pupil detection failed for one or both eyes") - } - } - } else { - if shouldLog { - if pixelBuffer == nil { - print("โš ๏ธ No pixel buffer available for pupil detection") - } else if !EyeTrackingConstants.pixelGazeEnabled { - print("โš ๏ธ Pixel gaze detection disabled in constants") - } else { - print("โš ๏ธ Missing eye landmarks for pupil detection") - } - } - } - - let isLookingAway = poseLookingAway || eyesLookingAway - - return isLookingAway - } - - /// Calculate gaze ratio using Python GazeTracking algorithm - /// Formula: ratio = pupilX / (eyeCenterX * 2 - 10) - /// Returns: 0.0-1.0 (0.0 = far right, 1.0 = far left) - private func calculateGazeRatio(pupilPosition: PupilPosition, eyeRegion: EyeRegion) -> Double { - let pupilX = Double(pupilPosition.x) - let eyeCenterX = Double(eyeRegion.center.x) - - // Python formula from GazeTracking library - let denominator = (eyeCenterX * 2.0 - 10.0) - - guard denominator > 0 else { - // Fallback to simple normalized position - let eyeLeft = Double(eyeRegion.frame.minX) - let eyeRight = Double(eyeRegion.frame.maxX) - let eyeWidth = eyeRight - eyeLeft - guard eyeWidth > 0 else { return 0.5 } - return (pupilX - eyeLeft) / eyeWidth - } - - let ratio = pupilX / denominator - - // Clamp to valid range - return max(0.0, min(1.0, ratio)) - } - } -extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate { - // DEBUG: Frame counter for periodic logging (nonisolated for video callback) - private nonisolated(unsafe) static var debugFrameCount = 0 - - nonisolated func captureOutput( - _ output: AVCaptureOutput, - didOutput sampleBuffer: CMSampleBuffer, - from connection: AVCaptureConnection +extension EyeTrackingService: CameraSessionDelegate { + nonisolated func cameraSession( + _ manager: CameraSessionManager, + didOutput pixelBuffer: CVPixelBuffer, + imageSize: CGSize ) { - // DEBUG: Print every 30 frames to show we're receiving video - #if DEBUG - EyeTrackingService.debugFrameCount += 1 - if EyeTrackingService.debugFrameCount % 30 == 0 { - NSLog("๐ŸŽฅ EyeTrackingService: Received frame %d", EyeTrackingService.debugFrameCount) - } - #endif - - guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { - return - } - - // Advance frame counter for pupil detector frame skipping PupilDetector.advanceFrame() - let request = VNDetectFaceLandmarksRequest { [weak self] request, error in - guard let self = self else { return } + let analysis = visionPipeline.analyze(pixelBuffer: pixelBuffer, imageSize: imageSize) + let result = gazeDetector.process(analysis: analysis, pixelBuffer: pixelBuffer) - if let error = error { - print("Face detection error: \(error)") - return - } - - let size = CGSize( - width: CVPixelBufferGetWidth(pixelBuffer), - height: CVPixelBufferGetHeight(pixelBuffer) - ) - - // Process face observations on the video queue (not main thread) - // to avoid UI freezes from heavy pupil detection - let observations = request.results as? [VNFaceObservation] - let result = self.processFaceObservationsSync( - observations, - imageSize: size, - pixelBuffer: pixelBuffer - ) - - // Only dispatch UI updates to main thread + if let leftRatio = result.leftPupilRatio, + let rightRatio = result.rightPupilRatio, + let faceWidth = result.faceWidthRatio { Task { @MainActor in - self.faceDetected = result.faceDetected - self.isEyesClosed = result.isEyesClosed - self.userLookingAtScreen = result.userLookingAtScreen - self.debugLeftPupilRatio = result.debugLeftPupilRatio - self.debugRightPupilRatio = result.debugRightPupilRatio - self.debugLeftVerticalRatio = result.debugLeftVerticalRatio - self.debugRightVerticalRatio = result.debugRightVerticalRatio - self.debugYaw = result.debugYaw - self.debugPitch = result.debugPitch - - // Update debug eye images from PupilDetector - if let leftInput = PupilDetector.debugLeftEyeInput { - self.debugLeftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height)) - } - if let rightInput = PupilDetector.debugRightEyeInput { - self.debugRightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height)) - } - if let leftProcessed = PupilDetector.debugLeftEyeProcessed { - self.debugLeftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height)) - } - if let rightProcessed = PupilDetector.debugRightEyeProcessed { - self.debugRightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height)) - } - self.debugLeftPupilPosition = PupilDetector.debugLeftPupilPosition - self.debugRightPupilPosition = PupilDetector.debugRightPupilPosition - self.debugLeftEyeSize = PupilDetector.debugLeftEyeSize - self.debugRightEyeSize = PupilDetector.debugRightEyeSize - - // Update eye region positions for video overlay - self.debugLeftEyeRegion = PupilDetector.debugLeftEyeRegion - self.debugRightEyeRegion = PupilDetector.debugRightEyeRegion - self.debugImageSize = PupilDetector.debugImageSize + guard CalibrationManager.shared.isCalibrating else { return } + calibrationBridge.submitSample( + leftRatio: leftRatio, + rightRatio: rightRatio, + leftVertical: result.leftVerticalRatio, + rightVertical: result.rightVerticalRatio, + faceWidthRatio: faceWidth + ) } } - // Use revision 3 which includes more detailed landmarks including pupils - request.revision = VNDetectFaceLandmarksRequestRevision3 - - // Enable constellation points which may help with pose estimation - if #available(macOS 14.0, *) { - request.constellation = .constellation76Points - } - - let imageRequestHandler = VNImageRequestHandler( - cvPixelBuffer: pixelBuffer, - orientation: .upMirrored, - options: [:] - ) - - do { - try imageRequestHandler.perform([request]) - } catch { - print("Failed to perform face detection: \(error)") + Task { @MainActor in + self.faceDetected = result.faceDetected + self.isEyesClosed = result.isEyesClosed + self.userLookingAtScreen = result.userLookingAtScreen + self.debugAdapter.update(from: result) + self.debugAdapter.updateEyeImages(from: PupilDetector.self) + self.syncDebugState() + self.updateGazeConfiguration() } } } diff --git a/Gaze/Services/ServiceContainer.swift b/Gaze/Services/ServiceContainer.swift index ea33d78..fc111e5 100644 --- a/Gaze/Services/ServiceContainer.swift +++ b/Gaze/Services/ServiceContainer.swift @@ -5,11 +5,9 @@ // Dependency injection container for managing service instances. // -import Combine import Foundation /// A simple dependency injection container for managing service instances. -/// Supports both production and test configurations. @MainActor final class ServiceContainer { @@ -34,27 +32,22 @@ final class ServiceContainer { /// The usage tracking service private(set) var usageTrackingService: UsageTrackingService? - /// Whether this container is configured for testing - let isTestEnvironment: Bool - /// Creates a production container with real services private init() { - self.isTestEnvironment = false self.settingsManager = SettingsManager.shared self.enforceModeService = EnforceModeService.shared } - - /// Creates a test container with injectable dependencies + + /// Creates a container with injectable dependencies /// - Parameters: /// - settingsManager: The settings manager to use /// - enforceModeService: The enforce mode service to use init( settingsManager: any SettingsProviding, - enforceModeService: EnforceModeService? = nil + enforceModeService: EnforceModeService ) { - self.isTestEnvironment = true self.settingsManager = settingsManager - self.enforceModeService = enforceModeService ?? EnforceModeService.shared + self.enforceModeService = enforceModeService } /// Gets or creates the timer engine @@ -65,17 +58,12 @@ final class ServiceContainer { let engine = TimerEngine( settingsManager: settingsManager, enforceModeService: enforceModeService, - timeProvider: isTestEnvironment ? MockTimeProvider() : SystemTimeProvider() + timeProvider: SystemTimeProvider() ) _timerEngine = engine return engine } - /// Sets a custom timer engine (useful for testing) - func setTimerEngine(_ engine: TimerEngine) { - _timerEngine = engine - } - /// Sets up smart mode services func setupSmartModeServices() { let settings = settingsManager.settings @@ -102,85 +90,4 @@ final class ServiceContainer { } } - /// Resets the container for testing purposes - func reset() { - _timerEngine?.stop() - _timerEngine = nil - fullscreenService = nil - idleService = nil - usageTrackingService = nil - } - - /// Creates a new container configured for testing with default mock settings - static func forTesting() -> ServiceContainer { - forTesting(settings: AppSettings()) - } - - /// Creates a new container configured for testing with custom settings - static func forTesting(settings: AppSettings) -> ServiceContainer { - let mockSettings = MockSettingsManager(settings: settings) - return ServiceContainer(settingsManager: mockSettings) - } -} - -/// A mock settings manager for use in ServiceContainer.forTesting() -/// This is a minimal implementation - use the full MockSettingsManager from tests for more features -@MainActor -@Observable -final class MockSettingsManager: SettingsProviding { - var settings: AppSettings - - @ObservationIgnored - private let _settingsSubject: CurrentValueSubject - - var settingsPublisher: AnyPublisher { - _settingsSubject.eraseToAnyPublisher() - } - - @ObservationIgnored - private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ - .lookAway: \.lookAwayTimer, - .blink: \.blinkTimer, - .posture: \.postureTimer, - ] - - convenience init() { - self.init(settings: AppSettings()) - } - - init(settings: AppSettings) { - self.settings = settings - self._settingsSubject = CurrentValueSubject(settings) - } - - func timerConfiguration(for type: TimerType) -> TimerConfiguration { - guard let keyPath = timerConfigKeyPaths[type] else { - preconditionFailure("Unknown timer type: \(type)") - } - return settings[keyPath: keyPath] - } - - func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { - guard let keyPath = timerConfigKeyPaths[type] else { - preconditionFailure("Unknown timer type: \(type)") - } - settings[keyPath: keyPath] = configuration - _settingsSubject.send(settings) - } - - func allTimerConfigurations() -> [TimerType: TimerConfiguration] { - var configs: [TimerType: TimerConfiguration] = [:] - for (type, keyPath) in timerConfigKeyPaths { - configs[type] = settings[keyPath: keyPath] - } - return configs - } - - func save() { _settingsSubject.send(settings) } - func saveImmediately() { _settingsSubject.send(settings) } - func load() {} - func resetToDefaults() { - settings = .defaults - _settingsSubject.send(settings) - } } diff --git a/Gaze/Services/SmartModeManager.swift b/Gaze/Services/SmartModeManager.swift deleted file mode 100644 index ae607ae..0000000 --- a/Gaze/Services/SmartModeManager.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// SmartModeManager.swift -// Gaze -// -// Handles smart mode features like idle detection and fullscreen detection. -// - -import Combine -import Foundation - -@MainActor -class SmartModeManager { - private var fullscreenService: FullscreenDetectionService? - private var idleService: IdleMonitoringService? - private var timerEngine: TimerEngine? - - private var cancellables = Set() - - func setupSmartMode( - timerEngine: TimerEngine, - fullscreenService: FullscreenDetectionService?, - idleService: IdleMonitoringService? - ) { - self.timerEngine = timerEngine - self.fullscreenService = fullscreenService - self.idleService = idleService - - // Subscribe to fullscreen state changes - fullscreenService?.$isFullscreenActive - .sink { [weak self] isFullscreen in - Task { @MainActor in - self?.handleFullscreenChange(isFullscreen: isFullscreen) - } - } - .store(in: &cancellables) - - // Subscribe to idle state changes - idleService?.$isIdle - .sink { [weak self] isIdle in - Task { @MainActor in - self?.handleIdleChange(isIdle: isIdle) - } - } - .store(in: &cancellables) - } - - private func handleFullscreenChange(isFullscreen: Bool) { - guard let timerEngine = timerEngine else { return } - guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnFullscreen else { return } - - if isFullscreen { - timerEngine.pauseAllTimers(reason: .fullscreen) - logInfo("โธ๏ธ Timers paused: fullscreen detected") - } else { - timerEngine.resumeAllTimers(reason: .fullscreen) - logInfo("โ–ถ๏ธ Timers resumed: fullscreen exited") - } - } - - private func handleIdleChange(isIdle: Bool) { - guard let timerEngine = timerEngine else { return } - guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnIdle else { return } - - if isIdle { - timerEngine.pauseAllTimers(reason: .idle) - logInfo("โธ๏ธ Timers paused: user idle") - } else { - timerEngine.resumeAllTimers(reason: .idle) - logInfo("โ–ถ๏ธ Timers resumed: user active") - } - } -} \ No newline at end of file diff --git a/Gaze/Services/Timer/ReminderTriggerService.swift b/Gaze/Services/Timer/ReminderTriggerService.swift new file mode 100644 index 0000000..1878142 --- /dev/null +++ b/Gaze/Services/Timer/ReminderTriggerService.swift @@ -0,0 +1,56 @@ +// +// ReminderTriggerService.swift +// Gaze +// +// Creates reminder events and coordinates enforce mode behavior. +// + +import Foundation + +@MainActor +final class ReminderTriggerService { + private let settingsProvider: any SettingsProviding + private let enforceModeService: EnforceModeService? + + init( + settingsProvider: any SettingsProviding, + enforceModeService: EnforceModeService? + ) { + self.settingsProvider = settingsProvider + self.enforceModeService = enforceModeService + } + + func reminderEvent(for identifier: TimerIdentifier) -> ReminderEvent? { + switch identifier { + case .builtIn(let type): + switch type { + case .lookAway: + return .lookAwayTriggered( + countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds + ) + case .blink: + return .blinkTriggered + case .posture: + return .postureTriggered + } + case .user(let id): + guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else { + return nil + } + return .userTimerTriggered(userTimer) + } + } + + func shouldPrepareEnforceMode(for identifier: TimerIdentifier, secondsRemaining: Int) -> Bool { + guard secondsRemaining <= 3 else { return false } + return enforceModeService?.shouldEnforceBreak(for: identifier) ?? false + } + + func prepareEnforceMode(secondsRemaining: Int) async { + await enforceModeService?.startCameraForLookawayTimer(secondsRemaining: secondsRemaining) + } + + func handleReminderDismissed() { + enforceModeService?.handleReminderDismissed() + } +} diff --git a/Gaze/Services/Timer/SmartModeCoordinator.swift b/Gaze/Services/Timer/SmartModeCoordinator.swift new file mode 100644 index 0000000..61f34f3 --- /dev/null +++ b/Gaze/Services/Timer/SmartModeCoordinator.swift @@ -0,0 +1,85 @@ +// +// SmartModeCoordinator.swift +// Gaze +// +// Coordinates smart mode pause/resume behavior. +// + +import Combine +import Foundation + +protocol SmartModeCoordinatorDelegate: AnyObject { + func smartModeDidRequestPauseAll(_ coordinator: SmartModeCoordinator, reason: PauseReason) + func smartModeDidRequestResumeAll(_ coordinator: SmartModeCoordinator, reason: PauseReason) +} + +@MainActor +final class SmartModeCoordinator { + weak var delegate: SmartModeCoordinatorDelegate? + + private var fullscreenService: FullscreenDetectionService? + private var idleService: IdleMonitoringService? + private var cancellables = Set() + private var settingsProvider: (any SettingsProviding)? + + init() {} + + func setup( + fullscreenService: FullscreenDetectionService?, + idleService: IdleMonitoringService?, + settingsProvider: any SettingsProviding + ) { + self.fullscreenService = fullscreenService + self.idleService = idleService + self.settingsProvider = settingsProvider + + fullscreenService?.$isFullscreenActive + .sink { [weak self] isFullscreen in + Task { @MainActor in + self?.handleFullscreenChange(isFullscreen: isFullscreen) + } + } + .store(in: &cancellables) + + idleService?.$isIdle + .sink { [weak self] isIdle in + Task { @MainActor in + self?.handleIdleChange(isIdle: isIdle) + } + } + .store(in: &cancellables) + } + + func teardown() { + cancellables.removeAll() + fullscreenService = nil + idleService = nil + settingsProvider = nil + } + + private func handleFullscreenChange(isFullscreen: Bool) { + guard let settingsProvider else { return } + guard settingsProvider.settings.smartMode.autoPauseOnFullscreen else { return } + + if isFullscreen { + delegate?.smartModeDidRequestPauseAll(self, reason: .fullscreen) + logInfo("โธ๏ธ Timers paused: fullscreen detected") + } else { + delegate?.smartModeDidRequestResumeAll(self, reason: .fullscreen) + logInfo("โ–ถ๏ธ Timers resumed: fullscreen exited") + } + } + + private func handleIdleChange(isIdle: Bool) { + guard let settingsProvider else { return } + guard settingsProvider.settings.smartMode.autoPauseOnIdle else { return } + + if isIdle { + delegate?.smartModeDidRequestPauseAll(self, reason: .idle) + logInfo("โธ๏ธ Timers paused: user idle") + } else { + delegate?.smartModeDidRequestResumeAll(self, reason: .idle) + logInfo("โ–ถ๏ธ Timers resumed: user active") + } + } +} diff --git a/Gaze/Services/Timer/TimerScheduler.swift b/Gaze/Services/Timer/TimerScheduler.swift new file mode 100644 index 0000000..0376bac --- /dev/null +++ b/Gaze/Services/Timer/TimerScheduler.swift @@ -0,0 +1,44 @@ +// +// TimerScheduler.swift +// Gaze +// +// Schedules timer ticks for TimerEngine. +// + +import Combine +import Foundation + +protocol TimerSchedulerDelegate: AnyObject { + func schedulerDidTick(_ scheduler: TimerScheduler) +} + +@MainActor +final class TimerScheduler { + weak var delegate: TimerSchedulerDelegate? + + private var timerSubscription: AnyCancellable? + private let timeProvider: TimeProviding + + init(timeProvider: TimeProviding) { + self.timeProvider = timeProvider + } + + var isRunning: Bool { + timerSubscription != nil + } + + func start() { + guard timerSubscription == nil else { return } + timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self else { return } + self.delegate?.schedulerDidTick(self) + } + } + + func stop() { + timerSubscription?.cancel() + timerSubscription = nil + } +} diff --git a/Gaze/Services/Timer/TimerStateManager.swift b/Gaze/Services/Timer/TimerStateManager.swift new file mode 100644 index 0000000..e5d45d2 --- /dev/null +++ b/Gaze/Services/Timer/TimerStateManager.swift @@ -0,0 +1,162 @@ +// +// TimerStateManager.swift +// Gaze +// +// Manages timer state transitions and reminder state. +// + +import Combine +import Foundation + +@MainActor +final class TimerStateManager: ObservableObject { + @Published private(set) var timerStates: [TimerIdentifier: TimerState] = [:] + @Published private(set) var activeReminder: ReminderEvent? + + func initializeTimers(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) { + var newStates: [TimerIdentifier: TimerState] = [:] + + for (identifier, config) in configurations where config.enabled { + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: config.intervalSeconds, + isPaused: false, + isActive: true + ) + } + + for userTimer in userTimers where userTimer.enabled { + let identifier = TimerIdentifier.user(id: userTimer.id) + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: userTimer.intervalMinutes * 60, + isPaused: false, + isActive: true + ) + } + + timerStates = newStates + } + + func updateConfigurations(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) { + var newStates: [TimerIdentifier: TimerState] = [:] + + for (identifier, config) in configurations { + if config.enabled { + if let existingState = timerStates[identifier] { + if existingState.originalIntervalSeconds != config.intervalSeconds { + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: config.intervalSeconds, + isPaused: existingState.isPaused, + isActive: true + ) + } else { + newStates[identifier] = existingState + } + } else { + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: config.intervalSeconds, + isPaused: false, + isActive: true + ) + } + } + } + + for userTimer in userTimers { + let identifier = TimerIdentifier.user(id: userTimer.id) + let newIntervalSeconds = userTimer.intervalMinutes * 60 + + if userTimer.enabled { + if let existingState = timerStates[identifier] { + if existingState.originalIntervalSeconds != newIntervalSeconds { + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: newIntervalSeconds, + isPaused: existingState.isPaused, + isActive: true + ) + } else { + newStates[identifier] = existingState + } + } else { + newStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: newIntervalSeconds, + isPaused: false, + isActive: true + ) + } + } + } + + timerStates = newStates + } + + func decrementTimer(identifier: TimerIdentifier) -> TimerState? { + guard var state = timerStates[identifier] else { return nil } + state.remainingSeconds -= 1 + timerStates[identifier] = state + return state + } + + func setReminder(_ reminder: ReminderEvent?) { + activeReminder = reminder + } + + func pauseAll(reason: PauseReason) { + for (id, var state) in timerStates { + state.pauseReasons.insert(reason) + state.isPaused = true + timerStates[id] = state + } + } + + func resumeAll(reason: PauseReason) { + for (id, var state) in timerStates { + state.pauseReasons.remove(reason) + state.isPaused = !state.pauseReasons.isEmpty + timerStates[id] = state + } + } + + func pauseTimer(identifier: TimerIdentifier, reason: PauseReason) { + guard var state = timerStates[identifier] else { return } + state.pauseReasons.insert(reason) + state.isPaused = true + timerStates[identifier] = state + } + + func resumeTimer(identifier: TimerIdentifier, reason: PauseReason) { + guard var state = timerStates[identifier] else { return } + state.pauseReasons.remove(reason) + state.isPaused = !state.pauseReasons.isEmpty + timerStates[identifier] = state + } + + func resetTimer(identifier: TimerIdentifier, intervalSeconds: Int) { + guard let state = timerStates[identifier] else { return } + timerStates[identifier] = TimerState( + identifier: identifier, + intervalSeconds: intervalSeconds, + isPaused: state.isPaused, + isActive: state.isActive + ) + } + + func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval { + guard let state = timerStates[identifier] else { return 0 } + return TimeInterval(state.remainingSeconds) + } + + func isTimerPaused(_ identifier: TimerIdentifier) -> Bool { + return timerStates[identifier]?.isPaused ?? true + } + + func clearAll() { + timerStates.removeAll() + activeReminder = nil + } +} diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index 95b6411..a805244 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -13,24 +13,12 @@ class TimerEngine: ObservableObject { @Published var timerStates: [TimerIdentifier: TimerState] = [:] @Published var activeReminder: ReminderEvent? - private var timerSubscription: AnyCancellable? private let settingsProvider: any SettingsProviding - - // Expose the settings provider for components that need it (like SmartModeManager) - var settingsProviderForTesting: any SettingsProviding { - return settingsProvider - } - private var sleepStartTime: Date? - - /// Time provider for deterministic testing (defaults to system time) private let timeProvider: TimeProviding - - // For enforce mode integration - private var enforceModeService: EnforceModeService? - - // Smart Mode services - private var fullscreenService: FullscreenDetectionService? - private var idleService: IdleMonitoringService? + private let stateManager = TimerStateManager() + private let scheduler: TimerScheduler + private let reminderService: ReminderTriggerService + private let smartModeCoordinator = SmartModeCoordinator() private var cancellables = Set() convenience init( @@ -50,127 +38,66 @@ class TimerEngine: ObservableObject { timeProvider: TimeProviding ) { self.settingsProvider = settingsManager - self.enforceModeService = enforceModeService ?? EnforceModeService.shared self.timeProvider = timeProvider + self.scheduler = TimerScheduler(timeProvider: timeProvider) + self.reminderService = ReminderTriggerService( + settingsProvider: settingsManager, + enforceModeService: enforceModeService ?? EnforceModeService.shared + ) Task { @MainActor in - self.enforceModeService?.setTimerEngine(self) + enforceModeService?.setTimerEngine(self) } + + scheduler.delegate = self + smartModeCoordinator.delegate = self + + stateManager.$timerStates + .sink { [weak self] states in + self?.timerStates = states + } + .store(in: &cancellables) + + stateManager.$activeReminder + .sink { [weak self] reminder in + self?.activeReminder = reminder + } + .store(in: &cancellables) } func setupSmartMode( fullscreenService: FullscreenDetectionService?, idleService: IdleMonitoringService? ) { - self.fullscreenService = fullscreenService - self.idleService = idleService - - // Subscribe to fullscreen state changes - fullscreenService?.$isFullscreenActive - .sink { [weak self] isFullscreen in - Task { @MainActor in - self?.handleFullscreenChange(isFullscreen: isFullscreen) - } - } - .store(in: &cancellables) - - // Subscribe to idle state changes - idleService?.$isIdle - .sink { [weak self] isIdle in - Task { @MainActor in - self?.handleIdleChange(isIdle: isIdle) - } - } - .store(in: &cancellables) - } - - private func handleFullscreenChange(isFullscreen: Bool) { - guard settingsProvider.settings.smartMode.autoPauseOnFullscreen else { return } - - if isFullscreen { - pauseAllTimers(reason: .fullscreen) - logInfo("โธ๏ธ Timers paused: fullscreen detected") - } else { - resumeAllTimers(reason: .fullscreen) - logInfo("โ–ถ๏ธ Timers resumed: fullscreen exited") - } - } - - private func handleIdleChange(isIdle: Bool) { - guard settingsProvider.settings.smartMode.autoPauseOnIdle else { return } - - if isIdle { - pauseAllTimers(reason: .idle) - logInfo("โธ๏ธ Timers paused: user idle") - } else { - resumeAllTimers(reason: .idle) - logInfo("โ–ถ๏ธ Timers resumed: user active") - } + smartModeCoordinator.setup( + fullscreenService: fullscreenService, + idleService: idleService, + settingsProvider: settingsProvider + ) } func pauseAllTimers(reason: PauseReason) { - for (id, var state) in timerStates { - state.pauseReasons.insert(reason) - state.isPaused = true - timerStates[id] = state - } + stateManager.pauseAll(reason: reason) } func resumeAllTimers(reason: PauseReason) { - for (id, var state) in timerStates { - state.pauseReasons.remove(reason) - state.isPaused = !state.pauseReasons.isEmpty - timerStates[id] = state - } + stateManager.resumeAll(reason: reason) } func start() { // If timers are already running, just update configurations without resetting - if timerSubscription != nil { + if scheduler.isRunning { updateConfigurations() return } // Initial start - create all timer states stop() - - var newStates: [TimerIdentifier: TimerState] = [:] - - // Add built-in timers (using unified approach) - for timerType in TimerType.allCases { - let config = settingsProvider.timerConfiguration(for: timerType) - if config.enabled { - let identifier = TimerIdentifier.builtIn(timerType) - newStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: config.intervalSeconds, - isPaused: false, - isActive: true - ) - } - } - - // Add user timers (using unified approach) - for userTimer in settingsProvider.settings.userTimers where userTimer.enabled { - let identifier = TimerIdentifier.user(id: userTimer.id) - newStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: userTimer.intervalMinutes * 60, - isPaused: false, - isActive: true - ) - } - - // Assign the entire dictionary at once to trigger @Published - timerStates = newStates - - timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - Task { @MainActor in - self?.handleTick() - } - } + stateManager.initializeTimers( + using: timerConfigurations(), + userTimers: settingsProvider.settings.userTimers + ) + scheduler.start() } /// Check if enforce mode is active and should affect timer behavior @@ -180,130 +107,36 @@ class TimerEngine: ObservableObject { private func updateConfigurations() { logDebug("Updating timer configurations") - var newStates: [TimerIdentifier: TimerState] = [:] - - // Update built-in timers (using unified approach) - for timerType in TimerType.allCases { - let config = settingsProvider.timerConfiguration(for: timerType) - let identifier = TimerIdentifier.builtIn(timerType) - - if config.enabled { - if let existingState = timerStates[identifier] { - // Timer exists - check if interval changed - if existingState.originalIntervalSeconds != config.intervalSeconds { - // Interval changed - reset with new interval - logDebug("Timer interval changed") - newStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: config.intervalSeconds, - isPaused: existingState.isPaused, - isActive: true - ) - } else { - // Interval unchanged - keep existing state - newStates[identifier] = existingState - } - } else { - // Timer was just enabled - create new state - logDebug("Timer enabled") - newStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: config.intervalSeconds, - isPaused: false, - isActive: true - ) - } - } - // If config.enabled is false and timer exists, it will be removed - } - - // Update user timers (using unified approach) - for userTimer in settingsProvider.settings.userTimers { - let identifier = TimerIdentifier.user(id: userTimer.id) - let newIntervalSeconds = userTimer.intervalMinutes * 60 - - if userTimer.enabled { - if let existingState = timerStates[identifier] { - // Check if interval changed - if existingState.originalIntervalSeconds != newIntervalSeconds { - // Interval changed - reset with new interval - logDebug("User timer interval changed") - newStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: newIntervalSeconds, - isPaused: existingState.isPaused, - isActive: true - ) - } else { - // Interval unchanged - keep existing state - newStates[identifier] = existingState - } - } else { - // New timer - create state - logDebug("User timer created") - newStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: newIntervalSeconds, - isPaused: false, - isActive: true - ) - } - } - // If timer is disabled, it will be removed - } - - // Assign the entire dictionary at once to trigger @Published - timerStates = newStates + stateManager.updateConfigurations( + using: timerConfigurations(), + userTimers: settingsProvider.settings.userTimers + ) } func stop() { - timerSubscription?.cancel() - timerSubscription = nil - timerStates.removeAll() + scheduler.stop() + stateManager.clearAll() } func pause() { - for (id, var state) in timerStates { - state.pauseReasons.insert(.manual) - state.isPaused = true - timerStates[id] = state - } + stateManager.pauseAll(reason: .manual) } func resume() { - for (id, var state) in timerStates { - state.pauseReasons.remove(.manual) - state.isPaused = !state.pauseReasons.isEmpty - timerStates[id] = state - } + stateManager.resumeAll(reason: .manual) } func pauseTimer(identifier: TimerIdentifier) { - guard var state = timerStates[identifier] else { return } - state.pauseReasons.insert(.manual) - state.isPaused = true - timerStates[identifier] = state + stateManager.pauseTimer(identifier: identifier, reason: .manual) } func resumeTimer(identifier: TimerIdentifier) { - guard var state = timerStates[identifier] else { return } - state.pauseReasons.remove(.manual) - state.isPaused = !state.pauseReasons.isEmpty - timerStates[identifier] = state + stateManager.resumeTimer(identifier: identifier, reason: .manual) } -func skipNext(identifier: TimerIdentifier) { - guard let state = timerStates[identifier] else { return } - - // Unified approach to get interval - no more separate handling for user timers + func skipNext(identifier: TimerIdentifier) { let intervalSeconds = getTimerInterval(for: identifier) - - timerStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: intervalSeconds, - isPaused: state.isPaused, - isActive: state.isActive - ) + stateManager.resetTimer(identifier: identifier, intervalSeconds: intervalSeconds) } /// Unified way to get interval for any timer type @@ -322,13 +155,13 @@ func skipNext(identifier: TimerIdentifier) { func dismissReminder() { guard let reminder = activeReminder else { return } - activeReminder = nil + stateManager.setReminder(nil) let identifier = reminder.identifier skipNext(identifier: identifier) resumeTimer(identifier: identifier) - enforceModeService?.handleReminderDismissed() + reminderService.handleReminderDismissed() } private func handleTick() { @@ -341,24 +174,22 @@ func skipNext(identifier: TimerIdentifier) { continue } - timerStates[identifier]?.remainingSeconds -= 1 + guard let updatedState = stateManager.decrementTimer(identifier: identifier) else { + continue + } - if let updatedState = timerStates[identifier] { - // Unified approach - no more special handling needed for any timer type - if updatedState.remainingSeconds <= 3 && !updatedState.isPaused { - // Enforce mode is handled generically, not specifically for lookAway only - if enforceModeService?.shouldEnforceBreak(for: identifier) == true { - Task { @MainActor in - await enforceModeService?.startCameraForLookawayTimer( - secondsRemaining: updatedState.remainingSeconds) - } - } + if reminderService.shouldPrepareEnforceMode( + for: identifier, + secondsRemaining: updatedState.remainingSeconds + ) { + Task { @MainActor in + await reminderService.prepareEnforceMode(secondsRemaining: updatedState.remainingSeconds) } + } - if updatedState.remainingSeconds <= 0 { - triggerReminder(for: identifier) - break - } + if updatedState.remainingSeconds <= 0 { + triggerReminder(for: identifier) + break } } } @@ -367,28 +198,13 @@ func skipNext(identifier: TimerIdentifier) { // Pause only the timer that triggered pauseTimer(identifier: identifier) - // Unified approach to handle all timer types - no more special handling - switch identifier { - case .builtIn(let type): - switch type { - case .lookAway: - activeReminder = .lookAwayTriggered( - countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds) - case .blink: - activeReminder = .blinkTriggered - case .posture: - activeReminder = .postureTriggered - } - case .user(let id): - if let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) { - activeReminder = .userTimerTriggered(userTimer) - } + if let reminder = reminderService.reminderEvent(for: identifier) { + stateManager.setReminder(reminder) } } func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval { - guard let state = timerStates[identifier] else { return 0 } - return TimeInterval(state.remainingSeconds) + stateManager.getTimeRemaining(for: identifier) } func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String { @@ -396,11 +212,11 @@ func skipNext(identifier: TimerIdentifier) { } func isTimerPaused(_ identifier: TimerIdentifier) -> Bool { - return timerStates[identifier]?.isPaused ?? true + return stateManager.isTimerPaused(identifier) } -// System sleep/wake handling is now managed by SystemSleepManager -// This method is kept for compatibility but will be removed in future versions + // System sleep/wake handling is now managed by SystemSleepManager + // This method is kept for compatibility but will be removed in future versions /// Handles system sleep event - deprecated @available(*, deprecated, message: "Use SystemSleepManager instead") func handleSystemSleep() { @@ -414,5 +230,29 @@ func skipNext(identifier: TimerIdentifier) { logDebug("System waking up (deprecated)") // This functionality has been moved to SystemSleepManager } + + private func timerConfigurations() -> [TimerIdentifier: TimerConfiguration] { + var configurations: [TimerIdentifier: TimerConfiguration] = [:] + for timerType in TimerType.allCases { + let config = settingsProvider.timerConfiguration(for: timerType) + configurations[.builtIn(timerType)] = config + } + return configurations + } } +extension TimerEngine: TimerSchedulerDelegate { + func schedulerDidTick(_ scheduler: TimerScheduler) { + handleTick() + } +} + +extension TimerEngine: SmartModeCoordinatorDelegate { + func smartModeDidRequestPauseAll(_ coordinator: SmartModeCoordinator, reason: PauseReason) { + pauseAllTimers(reason: reason) + } + + func smartModeDidRequestResumeAll(_ coordinator: SmartModeCoordinator, reason: PauseReason) { + resumeAllTimers(reason: reason) + } +} diff --git a/Gaze/Services/WindowManager.swift b/Gaze/Services/WindowManager.swift index faab9d1..2d7d396 100644 --- a/Gaze/Services/WindowManager.swift +++ b/Gaze/Services/WindowManager.swift @@ -93,7 +93,6 @@ final class WindowManager: WindowManaging { } func showSettings(settingsManager: any SettingsProviding, initialTab: Int) { - // Use the existing presenter for now if let realSettings = settingsManager as? SettingsManager { SettingsWindowPresenter.shared.show(settingsManager: realSettings, initialTab: initialTab) } diff --git a/Gaze/Views/Components/ExternalLinkButton.swift b/Gaze/Views/Components/ExternalLinkButton.swift new file mode 100644 index 0000000..1b57f2e --- /dev/null +++ b/Gaze/Views/Components/ExternalLinkButton.swift @@ -0,0 +1,74 @@ +// +// ExternalLinkButton.swift +// Gaze +// +// Reusable external link button component. +// + +import SwiftUI + +struct ExternalLinkButton: View { + let icon: String + var iconColor: Color = .primary + let title: String + let subtitle: String + let url: String + let tint: Color? + + var body: some View { + Button(action: openURL) { + HStack { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(iconColor) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + } + .padding() + .frame(maxWidth: .infinity) + .contentShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .glassEffectIfAvailable( + tint != nil ? GlassStyle.regular.tint(tint!).interactive() : GlassStyle.regular.interactive(), + in: .rect(cornerRadius: 10) + ) + } + + private func openURL() { + if let url = URL(string: url) { + NSWorkspace.shared.open(url) + } + } +} + +#Preview { + VStack { + ExternalLinkButton( + icon: "star.fill", + iconColor: .yellow, + title: "Example Link", + subtitle: "A subtitle describing the link", + url: "https://example.com", + tint: .blue + ) + + ExternalLinkButton( + icon: "link", + title: "Plain Link", + subtitle: "Without tint", + url: "https://example.com", + tint: nil + ) + } + .padding() +} diff --git a/Gaze/Views/Containers/SettingsWindowPresenter.swift b/Gaze/Views/Containers/SettingsWindowPresenter.swift new file mode 100644 index 0000000..669b581 --- /dev/null +++ b/Gaze/Views/Containers/SettingsWindowPresenter.swift @@ -0,0 +1,111 @@ +// +// SettingsWindowPresenter.swift +// Gaze +// +// Created by Mike Freno on 1/8/26. +// + +import AppKit +import SwiftUI + +@MainActor +final class SettingsWindowPresenter { + static let shared = SettingsWindowPresenter() + + static let switchTabNotification = Notification.Name("SwitchToSettingsTab") + + private var windowController: NSWindowController? + + private init() {} + + func show(settingsManager: SettingsManager, initialTab: Int = 0) { + if focusExistingWindow(tab: initialTab) { return } + createWindow(settingsManager: settingsManager, initialTab: initialTab) + } + + func show(settingsManager: SettingsManager, section: SettingsSection) { + show(settingsManager: settingsManager, initialTab: section.rawValue) + } + + func close() { + windowController?.close() + windowController = nil + } + + var isVisible: Bool { + windowController?.window?.isVisible ?? false + } + + @discardableResult + private func focusExistingWindow(tab: Int?) -> Bool { + guard let window = windowController?.window else { + windowController = nil + return false + } + + DispatchQueue.main.async { + if let tab { + NotificationCenter.default.post( + name: Self.switchTabNotification, + object: tab + ) + } + + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) + + if window.isMiniaturized { + window.deminiaturize(nil) + } + + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + return true + } + + private func createWindow(settingsManager: SettingsManager, initialTab: Int) { + let window = makeWindow() + + window.contentView = NSHostingView( + rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab) + ) + + let controller = NSWindowController(window: window) + controller.showWindow(nil) + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + + windowController = controller + } + + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect( + x: 0, y: 0, + width: AdaptiveLayout.Window.defaultWidth, + height: AdaptiveLayout.Window.defaultHeight + ), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.identifier = WindowIdentifiers.settings + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.toolbarStyle = .unified + window.showsToolbarButton = false + window.center() + window.setFrameAutosaveName("SettingsWindow") + window.isReleasedWhenClosed = false + + window.collectionBehavior = [ + .managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary, + ] + + return window + } +} diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index 772af2e..bd992a6 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -7,93 +7,6 @@ import SwiftUI -@MainActor -final class SettingsWindowPresenter { - static let shared = SettingsWindowPresenter() - - private var windowController: NSWindowController? - private var closeObserver: NSObjectProtocol? - - func show(settingsManager: SettingsManager, initialTab: Int = 0) { - if focusExistingWindow(tab: initialTab) { return } - - createWindow(settingsManager: settingsManager, initialTab: initialTab) - } - - func close() { - windowController?.close() - windowController = nil - } - - @discardableResult - private func focusExistingWindow(tab: Int?) -> Bool { - guard let window = windowController?.window else { - windowController = nil - return false - } - - DispatchQueue.main.async { - if let tab { - NotificationCenter.default.post( - name: Notification.Name("SwitchToSettingsTab"), - object: tab - ) - } - - NSApp.unhide(nil) - NSApp.activate(ignoringOtherApps: true) - - if window.isMiniaturized { - window.deminiaturize(nil) - } - - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - } - return true - } - - private func createWindow(settingsManager: SettingsManager, initialTab: Int) { - let window = NSWindow( - contentRect: NSRect( - x: 0, y: 0, - width: AdaptiveLayout.Window.defaultWidth, - height: AdaptiveLayout.Window.defaultHeight - ), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - - window.identifier = WindowIdentifiers.settings - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.toolbarStyle = .unified - window.showsToolbarButton = false - window.center() - window.setFrameAutosaveName("SettingsWindow") - window.isReleasedWhenClosed = false - - window.collectionBehavior = [ - .managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary, - ] - - window.contentView = NSHostingView( - rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab) - ) - - let controller = NSWindowController(window: window) - controller.showWindow(nil) - NSApp.unhide(nil) - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - - windowController = controller - - } -} - struct SettingsWindowView: View { @Bindable var settingsManager: SettingsManager @State private var selectedSection: SettingsSection @@ -106,52 +19,47 @@ struct SettingsWindowView: View { var body: some View { GeometryReader { geometry in let isCompact = geometry.size.height < 600 - + ZStack { VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) .ignoresSafeArea() VStack(spacing: 0) { - NavigationSplitView { - List(SettingsSection.allCases, selection: $selectedSection) { section in - NavigationLink(value: section) { - Label(section.title, systemImage: section.iconName) - } - } - .listStyle(.sidebar) - } detail: { - ScrollView { - detailView(for: selectedSection) - } - } - .onReceive( - NotificationCenter.default.publisher( - for: Notification.Name("SwitchToSettingsTab")) - ) { notification in - if let tab = notification.object as? Int, - let section = SettingsSection(rawValue: tab) - { - selectedSection = section - } - } + settingsContent #if DEBUG - Divider() - HStack { - Button("Retrigger Onboarding") { - retriggerOnboarding() - } - .buttonStyle(.bordered) - .controlSize(isCompact ? .small : .regular) - Spacer() - } - .padding(isCompact ? 8 : 16) + debugFooter(isCompact: isCompact) #endif } } .environment(\.isCompactLayout, isCompact) } .frame(minWidth: AdaptiveLayout.Window.minWidth, minHeight: AdaptiveLayout.Window.minHeight) + .onReceive(tabSwitchPublisher) { notification in + if let tab = notification.object as? Int, + let section = SettingsSection(rawValue: tab) { + selectedSection = section + } + } + } + + private var settingsContent: some View { + NavigationSplitView { + sidebarContent + } detail: { + ScrollView { + detailView(for: selectedSection) + } + } + } + + private var sidebarContent: some View { + List(SettingsSection.allCases, selection: $selectedSection) { section in + NavigationLink(value: section) { + Label(section.title, systemImage: section.iconName) + } + } + .listStyle(.sidebar) } @ViewBuilder @@ -179,14 +87,34 @@ struct SettingsWindowView: View { } } + private var tabSwitchPublisher: NotificationCenter.Publisher { + NotificationCenter.default.publisher( + for: SettingsWindowPresenter.switchTabNotification + ) + } + #if DEBUG - private func retriggerOnboarding() { - SettingsWindowPresenter.shared.close() - settingsManager.settings.hasCompletedOnboarding = false - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - OnboardingWindowPresenter.shared.show(settingsManager: settingsManager) + @ViewBuilder + private func debugFooter(isCompact: Bool) -> some View { + Divider() + HStack { + Button("Retrigger Onboarding") { + retriggerOnboarding() } + .buttonStyle(.bordered) + .controlSize(isCompact ? .small : .regular) + Spacer() } + .padding(isCompact ? 8 : 16) + } + + private func retriggerOnboarding() { + SettingsWindowPresenter.shared.close() + settingsManager.settings.hasCompletedOnboarding = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + OnboardingWindowPresenter.shared.show(settingsManager: settingsManager) + } + } #endif } diff --git a/Gaze/Views/Setup/GeneralSetupView.swift b/Gaze/Views/Setup/GeneralSetupView.swift index e60e845..7adadfa 100644 --- a/Gaze/Views/Setup/GeneralSetupView.swift +++ b/Gaze/Views/Setup/GeneralSetupView.swift @@ -9,14 +9,19 @@ import SwiftUI struct GeneralSetupView: View { @Bindable var settingsManager: SettingsManager - var updateManager = UpdateManager.shared var isOnboarding: Bool = true + #if !APPSTORE + var updateManager = UpdateManager.shared + #endif + var body: some View { VStack(spacing: 0) { SetupHeader( - icon: "gearshape.fill", title: isOnboarding ? "Final Settings" : "General Settings", - color: .accentColor) + icon: "gearshape.fill", + title: isOnboarding ? "Final Settings" : "General Settings", + color: .accentColor + ) Spacer() VStack(spacing: 30) { @@ -25,19 +30,7 @@ struct GeneralSetupView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) - VStack(spacing: 20) { - launchAtLoginToggle - - #if !APPSTORE - softwareUpdatesSection - #endif - - subtleReminderSizeSection - - #if !APPSTORE - supportSection - #endif - } + settingsContent } Spacer() } @@ -46,201 +39,21 @@ struct GeneralSetupView: View { .background(.clear) } - private var launchAtLoginToggle: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Launch at Login") - .font(.headline) - Text("Start Gaze automatically when you log in") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Toggle("", isOn: $settingsManager.settings.launchAtLogin) - .labelsHidden() - .onChange(of: settingsManager.settings.launchAtLogin) { _, isEnabled in - applyLaunchAtLoginSetting(enabled: isEnabled) - } + @ViewBuilder + private var settingsContent: some View { + VStack(spacing: 20) { + LaunchAtLoginSection(isEnabled: $settingsManager.settings.launchAtLogin) + + #if !APPSTORE + SoftwareUpdatesSection(updateManager: updateManager) + #endif + + ReminderSizeSection(selectedSize: $settingsManager.settings.subtleReminderSize) + + #if !APPSTORE + SupportSection() + #endif } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } - - #if !APPSTORE - private var softwareUpdatesSection: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Software Updates") - .font(.headline) - - if let lastCheck = updateManager.lastUpdateCheckDate { - Text("Last checked: \(lastCheck, style: .relative)") - .font(.caption) - .foregroundStyle(.secondary) - .italic() - } else { - Text("Never checked for updates") - .font(.caption) - .foregroundStyle(.secondary) - .italic() - } - } - - Spacer() - - Button("Check for Updates Now") { - updateManager.checkForUpdates() - } - .buttonStyle(.bordered) - - Toggle( - "Automatically check for updates", - isOn: Binding( - get: { updateManager.automaticallyChecksForUpdates }, - set: { updateManager.automaticallyChecksForUpdates = $0 } - ) - ) - .labelsHidden() - .help("Check for new versions of Gaze in the background") - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } - #endif - - private var subtleReminderSizeSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Subtle Reminder Size") - .font(.headline) - - Text("Adjust the size of blink and posture reminders") - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 12) { - ForEach(ReminderSize.allCases, id: \.self) { size in - Button(action: { settingsManager.settings.subtleReminderSize = size }) { - VStack(spacing: 8) { - Circle() - .fill( - settingsManager.settings.subtleReminderSize == size - ? Color.accentColor : Color.secondary.opacity(0.3) - ) - .frame(width: iconSize(for: size), height: iconSize(for: size)) - - Text(size.displayName) - .font(.caption) - .fontWeight( - settingsManager.settings.subtleReminderSize == size - ? .semibold : .regular - ) - .foregroundStyle( - settingsManager.settings.subtleReminderSize == size - ? .primary : .secondary) - } - .frame(maxWidth: .infinity, minHeight: 60) - .padding(.vertical, 12) - } - .glassEffectIfAvailable( - settingsManager.settings.subtleReminderSize == size - ? GlassStyle.regular.tint(.accentColor.opacity(0.3)) - : GlassStyle.regular, - in: .rect(cornerRadius: 10) - ) - } - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } - - #if !APPSTORE - private var supportSection: some View { - VStack(spacing: 12) { - Text("Support & Contribute") - .font(.headline) - .frame(maxWidth: .infinity, alignment: .leading) - - ExternalLinkButton( - icon: "chevron.left.forwardslash.chevron.right", - title: "View on GitHub", - subtitle: "Star the repo, report issues, contribute", - url: "https://github.com/mikefreno/Gaze", - tint: nil - ) - - ExternalLinkButton( - icon: "cup.and.saucer.fill", - iconColor: .brown, - title: "Buy Me a Coffee", - subtitle: "Support development of Gaze", - url: "https://buymeacoffee.com/mikefreno", - tint: .orange - ) - } - .padding() - } - #endif - - private func applyLaunchAtLoginSetting(enabled: Bool) { - do { - if enabled { - try LaunchAtLoginManager.enable() - } else { - try LaunchAtLoginManager.disable() - } - } catch {} - } - - private func iconSize(for size: ReminderSize) -> CGFloat { - switch size { - case .small: return 20 - case .medium: return 32 - case .large: return 48 - } - } -} - -struct ExternalLinkButton: View { - let icon: String - var iconColor: Color = .primary - let title: String - let subtitle: String - let url: String - let tint: Color? - - var body: some View { - Button(action: { - if let url = URL(string: url) { - NSWorkspace.shared.open(url) - } - }) { - HStack { - Image(systemName: icon) - .font(.title3) - .foregroundStyle(iconColor) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.subheadline) - .fontWeight(.semibold) - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - } - .padding() - .frame(maxWidth: .infinity) - .contentShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - .glassEffectIfAvailable( - tint != nil - ? GlassStyle.regular.tint(tint!).interactive() : GlassStyle.regular.interactive(), - in: .rect(cornerRadius: 10) - ) } } diff --git a/Gaze/Views/Setup/Sections/LaunchAtLoginSection.swift b/Gaze/Views/Setup/Sections/LaunchAtLoginSection.swift new file mode 100644 index 0000000..0ff5e6f --- /dev/null +++ b/Gaze/Views/Setup/Sections/LaunchAtLoginSection.swift @@ -0,0 +1,49 @@ +// +// LaunchAtLoginSection.swift +// Gaze +// +// Launch at login toggle section. +// + +import SwiftUI + +struct LaunchAtLoginSection: View { + @Binding var isEnabled: Bool + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Launch at Login") + .font(.headline) + Text("Start Gaze automatically when you log in") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: $isEnabled) + .labelsHidden() + .onChange(of: isEnabled) { _, newValue in + applyLaunchAtLoginSetting(enabled: newValue) + } + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + + private func applyLaunchAtLoginSetting(enabled: Bool) { + do { + if enabled { + try LaunchAtLoginManager.enable() + } else { + try LaunchAtLoginManager.disable() + } + } catch { + logError("โš ๏ธ Failed to set launch at login: \(error)") + } + } +} + +#Preview { + LaunchAtLoginSection(isEnabled: .constant(true)) + .padding() +} diff --git a/Gaze/Views/Setup/Sections/ReminderSizeSection.swift b/Gaze/Views/Setup/Sections/ReminderSizeSection.swift new file mode 100644 index 0000000..02c8521 --- /dev/null +++ b/Gaze/Views/Setup/Sections/ReminderSizeSection.swift @@ -0,0 +1,81 @@ +// +// ReminderSizeSection.swift +// Gaze +// +// Subtle reminder size picker section. +// + +import SwiftUI + +struct ReminderSizeSection: View { + @Binding var selectedSize: ReminderSize + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Subtle Reminder Size") + .font(.headline) + + Text("Adjust the size of blink and posture reminders") + .font(.caption) + .foregroundStyle(.secondary) + + sizeButtonRow + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + + private var sizeButtonRow: some View { + HStack(spacing: 12) { + ForEach(ReminderSize.allCases, id: \.self) { size in + ReminderSizeButton( + size: size, + isSelected: selectedSize == size, + action: { selectedSize = size } + ) + } + } + } +} + +private struct ReminderSizeButton: View { + let size: ReminderSize + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Circle() + .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3)) + .frame(width: iconSize, height: iconSize) + + Text(size.displayName) + .font(.caption) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundStyle(isSelected ? .primary : .secondary) + } + .frame(maxWidth: .infinity, minHeight: 60) + .padding(.vertical, 12) + } + .glassEffectIfAvailable( + isSelected + ? GlassStyle.regular.tint(.accentColor.opacity(0.3)) + : GlassStyle.regular, + in: .rect(cornerRadius: 10) + ) + } + + private var iconSize: CGFloat { + switch size { + case .small: return 20 + case .medium: return 32 + case .large: return 48 + } + } +} + +#Preview { + ReminderSizeSection(selectedSize: .constant(.medium)) + .padding() +} diff --git a/Gaze/Views/Setup/Sections/SoftwareUpdatesSection.swift b/Gaze/Views/Setup/Sections/SoftwareUpdatesSection.swift new file mode 100644 index 0000000..af1748d --- /dev/null +++ b/Gaze/Views/Setup/Sections/SoftwareUpdatesSection.swift @@ -0,0 +1,64 @@ +// +// SoftwareUpdatesSection.swift +// Gaze +// +// Software updates settings section. +// + +#if !APPSTORE +import SwiftUI + +struct SoftwareUpdatesSection: View { + @ObservedObject var updateManager: UpdateManager + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Software Updates") + .font(.headline) + + lastCheckText + } + + Spacer() + + Button("Check for Updates Now") { + updateManager.checkForUpdates() + } + .buttonStyle(.bordered) + + Toggle( + "Automatically check for updates", + isOn: Binding( + get: { updateManager.automaticallyChecksForUpdates }, + set: { updateManager.automaticallyChecksForUpdates = $0 } + ) + ) + .labelsHidden() + .help("Check for new versions of Gaze in the background") + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + + @ViewBuilder + private var lastCheckText: some View { + if let lastCheck = updateManager.lastUpdateCheckDate { + Text("Last checked: \(lastCheck, style: .relative)") + .font(.caption) + .foregroundStyle(.secondary) + .italic() + } else { + Text("Never checked for updates") + .font(.caption) + .foregroundStyle(.secondary) + .italic() + } + } +} + +#Preview { + SoftwareUpdatesSection(updateManager: UpdateManager.shared) + .padding() +} +#endif diff --git a/Gaze/Views/Setup/Sections/SupportSection.swift b/Gaze/Views/Setup/Sections/SupportSection.swift new file mode 100644 index 0000000..7afec43 --- /dev/null +++ b/Gaze/Views/Setup/Sections/SupportSection.swift @@ -0,0 +1,43 @@ +// +// SupportSection.swift +// Gaze +// +// Support and contribute links section. +// + +#if !APPSTORE +import SwiftUI + +struct SupportSection: View { + var body: some View { + VStack(spacing: 12) { + Text("Support & Contribute") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ExternalLinkButton( + icon: "chevron.left.forwardslash.chevron.right", + title: "View on GitHub", + subtitle: "Star the repo, report issues, contribute", + url: "https://github.com/mikefreno/Gaze", + tint: nil + ) + + ExternalLinkButton( + icon: "cup.and.saucer.fill", + iconColor: .brown, + title: "Buy Me a Coffee", + subtitle: "Support development of Gaze", + url: "https://buymeacoffee.com/mikefreno", + tint: .orange + ) + } + .padding() + } +} + +#Preview { + SupportSection() + .padding() +} +#endif diff --git a/GazeTests/Helpers/MockTimeProvider.swift b/GazeTests/Helpers/MockTimeProvider.swift new file mode 100644 index 0000000..faba016 --- /dev/null +++ b/GazeTests/Helpers/MockTimeProvider.swift @@ -0,0 +1,33 @@ +// +// MockTimeProvider.swift +// GazeTests +// +// Mock time provider for deterministic timer testing. +// + +import Foundation +@testable import Gaze + +/// A mock time provider for deterministic testing. +/// Allows manual control over time in tests. +final class MockTimeProvider: TimeProviding, @unchecked Sendable { + private var currentTime: Date + + init(startTime: Date = Date()) { + self.currentTime = startTime + } + + func now() -> Date { + currentTime + } + + /// Advances time by the specified interval + func advance(by interval: TimeInterval) { + currentTime = currentTime.addingTimeInterval(interval) + } + + /// Sets the current time to a specific date + func setTime(_ date: Date) { + currentTime = date + } +} diff --git a/GazeTests/Helpers/TestServiceContainer.swift b/GazeTests/Helpers/TestServiceContainer.swift new file mode 100644 index 0000000..9aa145c --- /dev/null +++ b/GazeTests/Helpers/TestServiceContainer.swift @@ -0,0 +1,65 @@ +// +// TestServiceContainer.swift +// GazeTests +// +// Test-specific dependency injection container. +// + +import Foundation +@testable import Gaze + +/// A dependency injection container configured for testing. +/// Provides injectable dependencies and test-specific utilities. +@MainActor +final class TestServiceContainer { + /// The settings manager instance + private(set) var settingsManager: any SettingsProviding + + /// The timer engine instance + private var _timerEngine: TimerEngine? + + /// Time provider for deterministic testing + let timeProvider: TimeProviding + + /// Creates a test container with default mock settings + convenience init() { + self.init(settings: AppSettings()) + } + + /// Creates a test container with custom settings + init(settings: AppSettings) { + self.settingsManager = EnhancedMockSettingsManager(settings: settings) + self.timeProvider = MockTimeProvider() + } + + /// Creates a test container with a custom settings manager + init(settingsManager: any SettingsProviding) { + self.settingsManager = settingsManager + self.timeProvider = MockTimeProvider() + } + + /// Gets or creates the timer engine for testing + var timerEngine: TimerEngine { + if let engine = _timerEngine { + return engine + } + let engine = TimerEngine( + settingsManager: settingsManager, + enforceModeService: nil, + timeProvider: timeProvider + ) + _timerEngine = engine + return engine + } + + /// Sets a custom timer engine + func setTimerEngine(_ engine: TimerEngine) { + _timerEngine = engine + } + + /// Resets the container for test isolation + func reset() { + _timerEngine?.stop() + _timerEngine = nil + } +} diff --git a/GazeTests/ServiceContainerTests.swift b/GazeTests/ServiceContainerTests.swift index 4acc440..ced627c 100644 --- a/GazeTests/ServiceContainerTests.swift +++ b/GazeTests/ServiceContainerTests.swift @@ -14,31 +14,30 @@ final class ServiceContainerTests: XCTestCase { func testProductionContainerCreation() { let container = ServiceContainer.shared - XCTAssertFalse(container.isTestEnvironment) XCTAssertNotNil(container.settingsManager) XCTAssertNotNil(container.enforceModeService) } func testTestContainerCreation() { let settings = AppSettings.onlyLookAwayEnabled - let container = ServiceContainer.forTesting(settings: settings) + let container = TestServiceContainer(settings: settings) - XCTAssertTrue(container.isTestEnvironment) XCTAssertEqual(container.settingsManager.settings.lookAwayTimer.enabled, true) XCTAssertEqual(container.settingsManager.settings.blinkTimer.enabled, false) } func testTimerEngineCreation() { - let container = ServiceContainer.forTesting() + let container = TestServiceContainer() let timerEngine = container.timerEngine XCTAssertNotNil(timerEngine) // Second access should return the same instance XCTAssertTrue(container.timerEngine === timerEngine) + XCTAssertTrue(container.timeProvider is MockTimeProvider) } func testCustomTimerEngineInjection() { - let container = ServiceContainer.forTesting() + let container = TestServiceContainer() let mockSettings = EnhancedMockSettingsManager(settings: .shortIntervals) let customEngine = TimerEngine( settingsManager: mockSettings, @@ -51,10 +50,10 @@ final class ServiceContainerTests: XCTestCase { } func testContainerReset() { - let container = ServiceContainer.forTesting() + let container = TestServiceContainer() // Access timer engine to create it - _ = container.timerEngine + let existingEngine = container.timerEngine // Reset should clear the timer engine container.reset() @@ -62,5 +61,6 @@ final class ServiceContainerTests: XCTestCase { // Accessing again should create a new instance let newEngine = container.timerEngine XCTAssertNotNil(newEngine) + XCTAssertFalse(existingEngine === newEngine) } } diff --git a/GazeTests/TestHelpers.swift b/GazeTests/TestHelpers.swift index 81d39c8..8e85af5 100644 --- a/GazeTests/TestHelpers.swift +++ b/GazeTests/TestHelpers.swift @@ -202,28 +202,28 @@ extension AppSettings { @MainActor func createTestContainer( settings: AppSettings = .defaults -) -> ServiceContainer { - return ServiceContainer.forTesting(settings: settings) +) -> TestServiceContainer { + return TestServiceContainer(settings: settings) } /// Creates a complete test environment with all mocks @MainActor struct TestEnvironment { - let container: ServiceContainer + let container: TestServiceContainer let windowManager: MockWindowManager let settingsManager: EnhancedMockSettingsManager let timeProvider: MockTimeProvider init(settings: AppSettings = .defaults) { self.settingsManager = EnhancedMockSettingsManager(settings: settings) - self.container = ServiceContainer(settingsManager: settingsManager) + self.container = TestServiceContainer(settingsManager: settingsManager) self.windowManager = MockWindowManager() self.timeProvider = MockTimeProvider() } /// Creates an AppDelegate with all test dependencies func createAppDelegate() -> AppDelegate { - return AppDelegate(serviceContainer: container, windowManager: windowManager) + return AppDelegate(serviceContainer: serviceContainer, windowManager: windowManager) } /// Resets all mock state @@ -231,6 +231,13 @@ struct TestEnvironment { windowManager.reset() settingsManager.reset() } + + private var serviceContainer: ServiceContainer { + ServiceContainer( + settingsManager: settingsManager, + enforceModeService: EnforceModeService.shared + ) + } } // MARK: - XCTest Extensions