diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 8134136..097c5cd 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -185,7 +185,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private func showReminder(_ event: ReminderEvent) { switch event { case .lookAwayTriggered(let countdownSeconds): - let view = LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak self] in + let view = LookAwayReminderView( + countdownSeconds: countdownSeconds, + enforceModeService: EnforceModeService.shared + ) { [weak self] in self?.timerEngine?.dismissReminder() } windowManager.showReminderWindow(view, windowType: .overlay) diff --git a/Gaze/Models/DefaultSettingsBuilder.swift b/Gaze/Models/DefaultSettingsBuilder.swift index 084d58a..f3492f3 100644 --- a/Gaze/Models/DefaultSettingsBuilder.swift +++ b/Gaze/Models/DefaultSettingsBuilder.swift @@ -17,8 +17,8 @@ struct DefaultSettingsBuilder { static let subtleReminderSize: ReminderSize = .medium static let smartMode: SmartModeSettings = .defaults static let enforceModeStrictness = 0.4 - static let enforceModeEyeBoxWidthFactor = 0.18 - static let enforceModeEyeBoxHeightFactor = 0.10 + static let enforceModeEyeBoxWidthFactor = 0.20 + static let enforceModeEyeBoxHeightFactor = 0.02 static let enforceModeCalibration: EnforceModeCalibration? = nil static let hasCompletedOnboarding = false static let launchAtLogin = false diff --git a/Gaze/Services/Calibration/EnforceModeCalibrationService.swift b/Gaze/Services/Calibration/EnforceModeCalibrationService.swift new file mode 100644 index 0000000..5cc51b6 --- /dev/null +++ b/Gaze/Services/Calibration/EnforceModeCalibrationService.swift @@ -0,0 +1,262 @@ +// +// EnforceModeCalibrationService.swift +// Gaze +// +// Created by Mike Freno on 2/1/26. +// + +import AppKit +import Combine +import Foundation +import SwiftUI + +@MainActor +final class EnforceModeCalibrationService: ObservableObject { + static let shared = EnforceModeCalibrationService() + + @Published var isCalibrating = false + @Published var isCollectingSamples = false + @Published var currentStep: CalibrationStep = .eyeBox + @Published var targetIndex = 0 + @Published var countdownProgress: Double = 1.0 + @Published var samplesCollected = 0 + + private var samples: [CalibrationSample] = [] + private let targets = CalibrationTarget.defaultTargets + let settingsManager = SettingsManager.shared + private let eyeTrackingService = EyeTrackingService.shared + + private var countdownTimer: Timer? + private var sampleTimer: Timer? + private let countdownDuration: TimeInterval = 1.0 + private let preCountdownPause: TimeInterval = 0.5 + private let sampleInterval: TimeInterval = 0.1 + private let samplesPerTarget = 12 + private var windowController: NSWindowController? + + func start() { + samples.removeAll() + targetIndex = 0 + currentStep = .eyeBox + isCollectingSamples = false + samplesCollected = 0 + countdownProgress = 1.0 + isCalibrating = true + } + + func presentOverlay() { + guard windowController == nil else { return } + guard let screen = NSScreen.main else { return } + + start() + + let window = KeyableWindow( + contentRect: screen.frame, + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.level = .screenSaver + window.isOpaque = true + window.backgroundColor = .black + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.acceptsMouseMovedEvents = true + window.ignoresMouseEvents = false + + let overlayView = EnforceModeCalibrationOverlayView() + window.contentView = NSHostingView(rootView: overlayView) + + windowController = NSWindowController(window: window) + windowController?.showWindow(nil) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func dismissOverlay() { + windowController?.close() + windowController = nil + isCalibrating = false + } + + func cancel() { + stopCountdown() + stopSampleCollection() + isCalibrating = false + } + + func advance() { + switch currentStep { + case .eyeBox: + currentStep = .targets + startCountdown() + case .targets: + if targetIndex < targets.count - 1 { + targetIndex += 1 + startCountdown() + } else { + finish() + } + case .complete: + isCalibrating = false + } + } + + func recordSample() { + let debugState = eyeTrackingService.currentDebugSnapshot() + guard let h = debugState.normalizedHorizontal, + let v = debugState.normalizedVertical, + let faceWidth = debugState.faceWidthRatio else { + return + } + + let target = targets[targetIndex] + samples.append( + CalibrationSample( + target: target, + horizontal: h, + vertical: v, + faceWidthRatio: faceWidth + ) + ) + } + + func currentTarget() -> CalibrationTarget { + targets[targetIndex] + } + + private func startCountdown() { + stopCountdown() + stopSampleCollection() + + countdownProgress = 1.0 + let startTime = Date() + countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + let elapsed = Date().timeIntervalSince(startTime) + let countdownElapsed = max(0, elapsed - self.preCountdownPause) + if elapsed < self.preCountdownPause { + self.countdownProgress = 1.0 + return + } + let remaining = max(0, self.countdownDuration - countdownElapsed) + self.countdownProgress = remaining / self.countdownDuration + if remaining <= 0 { + self.stopCountdown() + self.startSampleCollection() + } + } + } + } + + private func stopCountdown() { + countdownTimer?.invalidate() + countdownTimer = nil + countdownProgress = 1.0 + } + + private func startSampleCollection() { + stopSampleCollection() + samplesCollected = 0 + isCollectingSamples = true + sampleTimer = Timer.scheduledTimer(withTimeInterval: sampleInterval, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.recordSample() + self.samplesCollected += 1 + if self.samplesCollected >= self.samplesPerTarget { + self.stopSampleCollection() + self.advance() + } + } + } + } + + private func stopSampleCollection() { + sampleTimer?.invalidate() + sampleTimer = nil + isCollectingSamples = false + } + + private func finish() { + stopCountdown() + stopSampleCollection() + guard let calibration = CalibrationSample.makeCalibration(samples: samples) else { + currentStep = .complete + return + } + + settingsManager.settings.enforceModeCalibration = calibration + currentStep = .complete + } + + var progress: Double { + guard !targets.isEmpty else { return 0 } + return Double(targetIndex) / Double(targets.count) + } + + var progressText: String { + "\(min(targetIndex + 1, targets.count))/\(targets.count)" + } +} + +enum CalibrationStep: String { + case eyeBox + case targets + case complete +} + +struct CalibrationTarget: Identifiable, Sendable { + let id = UUID() + let x: CGFloat + let y: CGFloat + let label: String + + static let defaultTargets: [CalibrationTarget] = [ + CalibrationTarget(x: 0.1, y: 0.1, label: "Top Left"), + CalibrationTarget(x: 0.5, y: 0.1, label: "Top"), + CalibrationTarget(x: 0.9, y: 0.1, label: "Top Right"), + CalibrationTarget(x: 0.9, y: 0.5, label: "Right"), + CalibrationTarget(x: 0.9, y: 0.9, label: "Bottom Right"), + CalibrationTarget(x: 0.5, y: 0.9, label: "Bottom"), + CalibrationTarget(x: 0.1, y: 0.9, label: "Bottom Left"), + CalibrationTarget(x: 0.1, y: 0.5, label: "Left"), + CalibrationTarget(x: 0.5, y: 0.5, label: "Center") + ] +} + +private struct CalibrationSample: Sendable { + let target: CalibrationTarget + let horizontal: Double + let vertical: Double + let faceWidthRatio: Double + + static func makeCalibration(samples: [CalibrationSample]) -> EnforceModeCalibration? { + guard !samples.isEmpty else { return nil } + + let horizontalValues = samples.map { $0.horizontal } + let verticalValues = samples.map { $0.vertical } + let faceWidths = samples.map { $0.faceWidthRatio } + + guard let minH = horizontalValues.min(), + let maxH = horizontalValues.max(), + let minV = verticalValues.min(), + let maxV = verticalValues.max() else { + return nil + } + + let faceWidthMean = faceWidths.reduce(0, +) / Double(faceWidths.count) + + return EnforceModeCalibration( + createdAt: Date(), + eyeBoxWidthFactor: SettingsManager.shared.settings.enforceModeEyeBoxWidthFactor, + eyeBoxHeightFactor: SettingsManager.shared.settings.enforceModeEyeBoxHeightFactor, + faceWidthRatio: faceWidthMean, + horizontalMin: minH, + horizontalMax: maxH, + verticalMin: minV, + verticalMax: maxV + ) + } +} diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift index ad416c4..792ffcc 100644 --- a/Gaze/Services/EnforceModeService.swift +++ b/Gaze/Services/EnforceModeService.swift @@ -33,6 +33,7 @@ class EnforceModeService: ObservableObject { private var faceDetectionTimer: Timer? private var trackingDebugTimer: Timer? private var trackingLapStats = TrackingLapStats() + private var lastLookAwayTime: Date = .distantPast // MARK: - Configuration @@ -183,7 +184,7 @@ class EnforceModeService: ObservableObject { gazeState: GazeState, faceDetected: Bool ) -> ComplianceResult { - guard faceDetected else { return .faceNotDetected } + guard faceDetected else { return .compliant } switch gazeState { case .lookingAway: return .compliant @@ -242,11 +243,13 @@ class EnforceModeService: ObservableObject { switch compliance { case .compliant: + lastLookAwayTime = Date() userCompliedWithBreak = true case .notCompliant: userCompliedWithBreak = false case .faceNotDetected: - userCompliedWithBreak = false + lastLookAwayTime = Date() + userCompliedWithBreak = true } } @@ -321,11 +324,30 @@ class EnforceModeService: ObservableObject { let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime) if timeSinceLastDetection > faceDetectionTimeout { - logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode.") - disableEnforceMode() + logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Assuming look away.") + lastLookAwayTime = Date() + userCompliedWithBreak = true + lastFaceDetectionTime = Date() } } + func shouldAdvanceLookAwayCountdown() -> Bool { + guard isEnforceModeEnabled else { return true } + guard isCameraActive else { return true } + + if !eyeTrackingService.trackingResult.faceDetected { + lastLookAwayTime = Date() + return true + } + + if eyeTrackingService.trackingResult.gazeState == .lookingAway { + lastLookAwayTime = Date() + return true + } + + return Date().timeIntervalSince(lastLookAwayTime) <= 0.25 + } + // MARK: - Test Mode func startTestMode() async { diff --git a/Gaze/Services/EyeTracking/EyeTrackingService.swift b/Gaze/Services/EyeTracking/EyeTrackingService.swift index fbe1f6f..d18e3ba 100644 --- a/Gaze/Services/EyeTracking/EyeTrackingService.swift +++ b/Gaze/Services/EyeTracking/EyeTrackingService.swift @@ -72,12 +72,14 @@ class EyeTrackingService: NSObject, ObservableObject { baselineEnabled = false centerHorizontal = (calibration.horizontalMin + calibration.horizontalMax) / 2 centerVertical = (calibration.verticalMin + calibration.verticalMax) / 2 + processor.setFaceWidthBaseline(calibration.faceWidthRatio) } else { horizontalThreshold = TrackingConfig.default.horizontalAwayThreshold * scale verticalThreshold = TrackingConfig.default.verticalAwayThreshold * scale baselineEnabled = TrackingConfig.default.baselineEnabled centerHorizontal = TrackingConfig.default.defaultCenterHorizontal centerVertical = TrackingConfig.default.defaultCenterVertical + processor.resetBaseline() } let config = TrackingConfig( @@ -126,6 +128,10 @@ class EyeTrackingService: NSObject, ObservableObject { debugState = EyeTrackingDebugState.empty } } + + func currentDebugSnapshot() -> EyeTrackingDebugState { + debugState + } } extension EyeTrackingService: CameraSessionDelegate { diff --git a/Gaze/Services/EyeTracking/VisionGazeProcessor.swift b/Gaze/Services/EyeTracking/VisionGazeProcessor.swift index 4054365..a13768f 100644 --- a/Gaze/Services/EyeTracking/VisionGazeProcessor.swift +++ b/Gaze/Services/EyeTracking/VisionGazeProcessor.swift @@ -48,6 +48,11 @@ final class VisionGazeProcessor: @unchecked Sendable { faceWidthSmoothed = nil } + func setFaceWidthBaseline(_ value: Double) { + faceWidthBaseline = value + faceWidthSmoothed = value + } + func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult { guard analysis.faceDetected, let face = analysis.face?.value else { return ObservationResult( diff --git a/Gaze/Views/Components/EnforceModeCalibrationOverlayView.swift b/Gaze/Views/Components/EnforceModeCalibrationOverlayView.swift new file mode 100644 index 0000000..7a1d3ec --- /dev/null +++ b/Gaze/Views/Components/EnforceModeCalibrationOverlayView.swift @@ -0,0 +1,184 @@ +// +// EnforceModeCalibrationOverlayView.swift +// Gaze +// +// Created by Mike Freno on 2/1/26. +// + +import SwiftUI + +struct EnforceModeCalibrationOverlayView: View { + @ObservedObject private var calibrationService = EnforceModeCalibrationService.shared + @ObservedObject private var eyeTrackingService = EyeTrackingService.shared + @Bindable private var settingsManager = SettingsManager.shared + + @ObservedObject private var enforceModeService = EnforceModeService.shared + + var body: some View { + ZStack { + Color.black.opacity(0.85) + .ignoresSafeArea() + + switch calibrationService.currentStep { + case .eyeBox: + eyeBoxStep + case .targets: + targetStep + case .complete: + completionStep + } + } + } + + private var eyeBoxStep: some View { + VStack(spacing: 24) { + Text("Adjust Eye Box") + .font(.title2) + .foregroundStyle(.white) + + Text( + "Use the sliders to fit the boxes around your eyes. When it looks right, continue." + ) + .font(.callout) + .multilineTextAlignment(.center) + .foregroundStyle(.white.opacity(0.8)) + + eyePreview + + VStack(alignment: .leading, spacing: 12) { + Text("Width") + .font(.caption) + .foregroundStyle(.white.opacity(0.8)) + Slider( + value: $settingsManager.settings.enforceModeEyeBoxWidthFactor, + in: 0.12...0.25 + ) + + Text("Height") + .font(.caption) + .foregroundStyle(.white.opacity(0.8)) + Slider( + value: $settingsManager.settings.enforceModeEyeBoxHeightFactor, + in: 0.01...0.10 + ) + } + .padding() + .background(.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + HStack(spacing: 12) { + Button("Cancel") { + calibrationService.dismissOverlay() + enforceModeService.stopTestMode() + } + .buttonStyle(.bordered) + + Button("Continue") { + calibrationService.advance() + } + .buttonStyle(.borderedProminent) + } + } + .padding() + } + + private var targetStep: some View { + ZStack { + VStack(spacing: 10) { + HStack { + Text("Calibrating...") + .foregroundStyle(.white) + Spacer() + Text(calibrationService.progressText) + .foregroundStyle(.white.opacity(0.7)) + } + + ProgressView(value: calibrationService.progress) + .progressViewStyle(.linear) + .tint(.blue) + } + .padding() + .background(Color.black.opacity(0.7)) + .frame(maxWidth: .infinity, alignment: .top) + + targetDot + + + VStack { + Spacer() + HStack(spacing: 12) { + Button("Cancel") { + calibrationService.dismissOverlay() + enforceModeService.stopTestMode() + } + .buttonStyle(.bordered) + } + } + .padding(.bottom, 40) + } + } + + private var completionStep: some View { + VStack(spacing: 20) { + Text("Calibration Complete") + .font(.title2) + .foregroundStyle(.white) + Text("You can close this window and start testing.") + .font(.callout) + .foregroundStyle(.white.opacity(0.8)) + + Button("Done") { + calibrationService.dismissOverlay() + enforceModeService.stopTestMode() + } + .buttonStyle(.borderedProminent) + } + } + + private var eyePreview: some View { + ZStack { + if let layer = eyeTrackingService.previewLayer { + CameraPreviewView(previewLayer: layer, borderColor: NSColor.systemBlue) + .frame(height: 240) + } + GeometryReader { geometry in + EyeTrackingDebugOverlayView( + debugState: eyeTrackingService.debugState, + viewSize: geometry.size + ) + } + } + .frame(height: 240) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var targetDot: some View { + GeometryReader { geometry in + let target = calibrationService.currentTarget() + Circle() + .fill(Color.blue) + .frame(width: 100, height: 100) + .position( + x: geometry.size.width * target.x, + y: geometry.size.height * target.y + ) + .overlay( + Circle() + .trim(from: 0, to: CGFloat(calibrationService.countdownProgress)) + .stroke(Color.blue.opacity(0.8), lineWidth: 6) + .frame(width: 140, height: 140) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 0.02), value: calibrationService.countdownProgress) + ) + } + .ignoresSafeArea() + } + + private var countdownRing: some View { + Circle() + .trim(from: 0, to: CGFloat(calibrationService.countdownProgress)) + .stroke(Color.blue.opacity(0.8), lineWidth: 6) + .frame(width: 120, height: 120) + .rotationEffect(.degrees(-90)) + } +} diff --git a/Gaze/Views/Components/EnforceModeSetupContent.swift b/Gaze/Views/Components/EnforceModeSetupContent.swift index edc197c..8786ab9 100644 --- a/Gaze/Views/Components/EnforceModeSetupContent.swift +++ b/Gaze/Views/Components/EnforceModeSetupContent.swift @@ -14,6 +14,7 @@ struct EnforceModeSetupContent: View { @ObservedObject var cameraService = CameraAccessService.shared @ObservedObject var eyeTrackingService = EyeTrackingService.shared @ObservedObject var enforceModeService = EnforceModeService.shared + @ObservedObject var calibrationService = EnforceModeCalibrationService.shared @Environment(\.isCompactLayout) private var isCompact let presentation: SetupPresentation @@ -79,12 +80,7 @@ struct EnforceModeSetupContent: View { if enforceModeService.isEnforceModeEnabled { strictnessControlView } - if isTestModeActive && enforceModeService.isCameraActive { - eyeBoxControlView - } - if enforceModeService.isCameraActive { - trackingLapButton - } + calibrationActionView privacyInfoView } @@ -399,44 +395,16 @@ struct EnforceModeSetupContent: View { .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) } - private var eyeBoxControlView: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Eye Box Size") - .font(headerFont) - - VStack(alignment: .leading, spacing: 8) { - Text("Width") - .font(.caption2) - .foregroundStyle(.secondary) - - Slider( - value: $settingsManager.settings.enforceModeEyeBoxWidthFactor, - in: 0.12...0.25 - ) - .controlSize(.small) - - Text("Height") - .font(.caption2) - .foregroundStyle(.secondary) - - Slider( - value: $settingsManager.settings.enforceModeEyeBoxHeightFactor, - in: 0.02...0.05 - ) - .controlSize(.small) - } - } - .padding(sectionPadding) - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) - } - - private var trackingLapButton: some View { + private var calibrationActionView: some View { Button(action: { - enforceModeService.logTrackingLap() + calibrationService.presentOverlay() + Task { @MainActor in + await enforceModeService.startTestMode() + } }) { HStack { - Image(systemName: "flag.checkered") - Text("Lap Marker") + Image(systemName: "target") + Text("Calibrate Eye Tracking") .font(.headline) } .frame(maxWidth: .infinity) @@ -445,4 +413,6 @@ struct EnforceModeSetupContent: View { .buttonStyle(.bordered) .controlSize(.regular) } + + } diff --git a/Gaze/Views/Reminders/LookAwayReminderView.swift b/Gaze/Views/Reminders/LookAwayReminderView.swift index a4acd89..d65a72e 100644 --- a/Gaze/Views/Reminders/LookAwayReminderView.swift +++ b/Gaze/Views/Reminders/LookAwayReminderView.swift @@ -12,15 +12,23 @@ import SwiftUI struct LookAwayReminderView: View { let countdownSeconds: Int var onDismiss: () -> Void + var enforceModeService: EnforceModeService? @State private var remainingSeconds: Int + @State private var remainingTime: TimeInterval @State private var timer: Timer? @State private var keyMonitor: Any? - init(countdownSeconds: Int, onDismiss: @escaping () -> Void) { + init( + countdownSeconds: Int, + enforceModeService: EnforceModeService? = nil, + onDismiss: @escaping () -> Void + ) { self.countdownSeconds = countdownSeconds + self.enforceModeService = enforceModeService self.onDismiss = onDismiss self._remainingSeconds = State(initialValue: countdownSeconds) + self._remainingTime = State(initialValue: TimeInterval(countdownSeconds)) } var body: some View { @@ -100,15 +108,21 @@ struct LookAwayReminderView: View { } private var progress: CGFloat { - CGFloat(remainingSeconds) / CGFloat(countdownSeconds) + CGFloat(remainingTime) / CGFloat(countdownSeconds) } private func startCountdown() { - let timer = Timer(timeInterval: 1.0, repeats: true) { [self] _ in - if remainingSeconds > 0 { - remainingSeconds -= 1 - } else { + let tickInterval: TimeInterval = 0.25 + let timer = Timer(timeInterval: tickInterval, repeats: true) { [self] _ in + guard remainingTime > 0 else { dismiss() + return + } + + let shouldAdvance = enforceModeService?.shouldAdvanceLookAwayCountdown() ?? true + if shouldAdvance { + remainingTime = max(0, remainingTime - tickInterval) + remainingSeconds = max(0, Int(ceil(remainingTime))) } } RunLoop.current.add(timer, forMode: .common)