turns out - vision framework aint good enough for this
This commit is contained in:
@@ -16,7 +16,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
private var updateManager: UpdateManager?
|
private var updateManager: UpdateManager?
|
||||||
private var overlayReminderWindowController: NSWindowController?
|
private var overlayReminderWindowController: NSWindowController?
|
||||||
private var subtleReminderWindowController: NSWindowController?
|
private var subtleReminderWindowController: NSWindowController?
|
||||||
private var settingsWindowController: NSWindowController?
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var hasStartedTimers = false
|
private var hasStartedTimers = false
|
||||||
|
|
||||||
@@ -271,120 +270,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
subtleReminderWindowController = nil
|
subtleReminderWindowController = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public method to open settings window
|
|
||||||
func openSettings(tab: Int = 0) {
|
func openSettings(tab: Int = 0) {
|
||||||
// Post notification to close menu bar popover
|
handleMenuDismissal()
|
||||||
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil)
|
|
||||||
|
|
||||||
// Dismiss overlay reminders to prevent them from blocking settings window
|
|
||||||
// Overlay reminders are at .floating level which would sit above settings
|
|
||||||
dismissOverlayReminder()
|
|
||||||
|
|
||||||
// Small delay to allow menu bar to close before opening settings
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
self?.openSettingsWindow(tab: tab)
|
guard let self else { return }
|
||||||
|
SettingsWindowPresenter.shared.show(settingsManager: self.settingsManager, initialTab: tab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public method to reopen onboarding window
|
|
||||||
func openOnboarding() {
|
func openOnboarding() {
|
||||||
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil)
|
handleMenuDismissal()
|
||||||
|
|
||||||
// Dismiss overlay reminders to prevent blocking onboarding window
|
|
||||||
dismissOverlayReminder()
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self else { return }
|
||||||
|
OnboardingWindowPresenter.shared.show(settingsManager: self.settingsManager)
|
||||||
if self.activateWindow(withIdentifier: WindowIdentifiers.onboarding) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let window = NSWindow(
|
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
|
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
|
|
||||||
backing: .buffered,
|
|
||||||
defer: false
|
|
||||||
)
|
|
||||||
|
|
||||||
window.identifier = WindowIdentifiers.onboarding
|
|
||||||
window.titleVisibility = .hidden
|
|
||||||
window.titlebarAppearsTransparent = true
|
|
||||||
window.center()
|
|
||||||
window.isReleasedWhenClosed = true
|
|
||||||
window.contentView = NSHostingView(
|
|
||||||
rootView: OnboardingContainerView(settingsManager: self.settingsManager)
|
|
||||||
)
|
|
||||||
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openSettingsWindow(tab: Int) {
|
private func handleMenuDismissal() {
|
||||||
if let existingWindow = findWindow(withIdentifier: WindowIdentifiers.settings) {
|
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil)
|
||||||
NotificationCenter.default.post(
|
dismissOverlayReminder()
|
||||||
name: Notification.Name("SwitchToSettingsTab"),
|
|
||||||
object: tab
|
|
||||||
)
|
|
||||||
existingWindow.makeKeyAndOrderFront(nil)
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let window = NSWindow(
|
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
|
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
|
||||||
backing: .buffered,
|
|
||||||
defer: false
|
|
||||||
)
|
|
||||||
|
|
||||||
window.identifier = WindowIdentifiers.settings
|
|
||||||
window.titleVisibility = .hidden
|
|
||||||
window.titlebarAppearsTransparent = true
|
|
||||||
window.toolbarStyle = .unified
|
|
||||||
window.toolbar = NSToolbar()
|
|
||||||
window.center()
|
|
||||||
window.setFrameAutosaveName("SettingsWindow")
|
|
||||||
window.isReleasedWhenClosed = false
|
|
||||||
|
|
||||||
window.contentView = NSHostingView(
|
|
||||||
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: tab)
|
|
||||||
)
|
|
||||||
|
|
||||||
let windowController = NSWindowController(window: window)
|
|
||||||
windowController.showWindow(nil)
|
|
||||||
|
|
||||||
settingsWindowController = windowController
|
|
||||||
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(settingsWindowWillCloseNotification(_:)),
|
|
||||||
name: NSWindow.willCloseNotification,
|
|
||||||
object: window
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func settingsWindowWillCloseNotification(_ notification: Notification) {
|
|
||||||
settingsWindowController = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finds a window by its identifier
|
|
||||||
private func findWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> NSWindow? {
|
|
||||||
return NSApplication.shared.windows.first { $0.identifier == identifier }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Brings window to front if it exists, returns true if found
|
|
||||||
private func activateWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> Bool {
|
|
||||||
guard let window = findWindow(withIdentifier: identifier) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom window class that can become key to receive keyboard events
|
// Custom window class that can become key to receive keyboard events
|
||||||
|
|||||||
@@ -5,9 +5,12 @@
|
|||||||
// Created by Mike Freno on 1/14/26.
|
// Created by Mike Freno on 1/14/26.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum EyeTrackingConstants {
|
class EyeTrackingConstants: ObservableObject {
|
||||||
|
static let shared = EyeTrackingConstants()
|
||||||
|
|
||||||
// MARK: - Logging
|
// MARK: - Logging
|
||||||
/// Interval between log messages in seconds
|
/// Interval between log messages in seconds
|
||||||
static let logInterval: TimeInterval = 0.5
|
static let logInterval: TimeInterval = 0.5
|
||||||
@@ -15,29 +18,57 @@ enum EyeTrackingConstants {
|
|||||||
// MARK: - Eye Closure Detection
|
// MARK: - Eye Closure Detection
|
||||||
/// Threshold for eye closure (smaller value means eye must be more closed to trigger)
|
/// Threshold for eye closure (smaller value means eye must be more closed to trigger)
|
||||||
/// Range: 0.0 to 1.0 (approximate eye opening ratio)
|
/// Range: 0.0 to 1.0 (approximate eye opening ratio)
|
||||||
static let eyeClosedThreshold: CGFloat = 0.02
|
@Published var eyeClosedThreshold: CGFloat = 0.02
|
||||||
|
@Published var eyeClosedEnabled: Bool = true
|
||||||
|
|
||||||
// MARK: - Face Pose Thresholds
|
// MARK: - Face Pose Thresholds
|
||||||
/// Maximum yaw (left/right head turn) in radians before considering user looking away
|
/// Maximum yaw (left/right head turn) in radians before considering user looking away
|
||||||
/// 0.20 radians ≈ 11.5 degrees (Tightened from 0.35)
|
/// 0.20 radians ≈ 11.5 degrees (Tightened from 0.35)
|
||||||
static let yawThreshold: Double = 0.2
|
/// NOTE: Vision Framework often provides unreliable yaw/pitch on macOS - disabled by default
|
||||||
|
@Published var yawThreshold: Double = 0.3
|
||||||
|
@Published var yawEnabled: Bool = false
|
||||||
|
|
||||||
/// Pitch threshold for looking UP (above screen).
|
/// Pitch threshold for looking UP (above screen).
|
||||||
/// Since camera is at top, looking at screen is negative pitch.
|
/// Since camera is at top, looking at screen is negative pitch.
|
||||||
/// Values > 0.1 imply looking straight ahead or up (away from screen).
|
/// Values > 0.1 imply looking straight ahead or up (away from screen).
|
||||||
static let pitchUpThreshold: Double = 0.1
|
/// NOTE: Vision Framework often doesn't provide pitch data on macOS - disabled by default
|
||||||
|
@Published var pitchUpThreshold: Double = 0.1
|
||||||
|
@Published var pitchUpEnabled: Bool = false
|
||||||
|
|
||||||
/// Pitch threshold for looking DOWN (at keyboard/lap).
|
/// Pitch threshold for looking DOWN (at keyboard/lap).
|
||||||
/// Values < -0.45 imply looking too far down.
|
/// Values < -0.45 imply looking too far down.
|
||||||
static let pitchDownThreshold: Double = -0.45
|
/// NOTE: Vision Framework often doesn't provide pitch data on macOS - disabled by default
|
||||||
|
@Published var pitchDownThreshold: Double = -0.45
|
||||||
|
@Published var pitchDownEnabled: Bool = false
|
||||||
|
|
||||||
// MARK: - Pupil Tracking Thresholds
|
// MARK: - Pupil Tracking Thresholds
|
||||||
/// Minimum horizontal pupil ratio (0.0 = right edge, 1.0 = left edge)
|
/// Minimum horizontal pupil ratio (0.0 = right edge, 1.0 = left edge)
|
||||||
/// Values below this are considered looking right (camera view)
|
/// Values below this are considered looking right (camera view)
|
||||||
static let minPupilRatio: Double = 0.40
|
/// Tightened to 0.35 based on observed values (typically 0.31-0.47)
|
||||||
|
@Published var minPupilRatio: Double = 0.35
|
||||||
|
@Published var minPupilEnabled: Bool = true
|
||||||
|
|
||||||
/// Maximum horizontal pupil ratio
|
/// Maximum horizontal pupil ratio
|
||||||
/// Values above this are considered looking left (camera view)
|
/// Values above this are considered looking left (camera view)
|
||||||
/// Tightened from 0.75 to 0.65
|
/// Tightened to 0.45 based on observed values (typically 0.31-0.47)
|
||||||
static let maxPupilRatio: Double = 0.6
|
@Published var maxPupilRatio: Double = 0.45
|
||||||
|
@Published var maxPupilEnabled: Bool = true
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Reset to Defaults
|
||||||
|
func resetToDefaults() {
|
||||||
|
eyeClosedThreshold = 0.02
|
||||||
|
eyeClosedEnabled = true
|
||||||
|
yawThreshold = 0.3
|
||||||
|
yawEnabled = false // Disabled by default - Vision Framework unreliable on macOS
|
||||||
|
pitchUpThreshold = 0.1
|
||||||
|
pitchUpEnabled = false // Disabled by default - often not available on macOS
|
||||||
|
pitchDownThreshold = -0.45
|
||||||
|
pitchDownEnabled = false // Disabled by default - often not available on macOS
|
||||||
|
minPupilRatio = 0.35
|
||||||
|
minPupilEnabled = true
|
||||||
|
maxPupilRatio = 0.45
|
||||||
|
maxPupilEnabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
Gaze/Protocols/EnforceModeProviding.swift
Normal file
57
Gaze/Protocols/EnforceModeProviding.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// EnforceModeProviding.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Protocol abstraction for EnforceModeService to enable dependency injection and testing.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Protocol that defines the interface for enforce mode functionality.
|
||||||
|
@MainActor
|
||||||
|
protocol EnforceModeProviding: AnyObject, ObservableObject {
|
||||||
|
/// Whether enforce mode is currently enabled
|
||||||
|
var isEnforceModeEnabled: Bool { get }
|
||||||
|
|
||||||
|
/// Whether the camera is currently active
|
||||||
|
var isCameraActive: Bool { get }
|
||||||
|
|
||||||
|
/// Whether the user has complied with the break
|
||||||
|
var userCompliedWithBreak: Bool { get }
|
||||||
|
|
||||||
|
/// Whether we're in test mode
|
||||||
|
var isTestMode: Bool { get }
|
||||||
|
|
||||||
|
/// Enables enforce mode (may request camera permission)
|
||||||
|
func enableEnforceMode() async
|
||||||
|
|
||||||
|
/// Disables enforce mode
|
||||||
|
func disableEnforceMode()
|
||||||
|
|
||||||
|
/// Sets the timer engine reference
|
||||||
|
func setTimerEngine(_ engine: TimerEngine)
|
||||||
|
|
||||||
|
/// Checks if a break should be enforced for the given timer
|
||||||
|
func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool
|
||||||
|
|
||||||
|
/// Starts the camera for lookaway timer
|
||||||
|
func startCameraForLookawayTimer(secondsRemaining: Int) async
|
||||||
|
|
||||||
|
/// Stops the camera
|
||||||
|
func stopCamera()
|
||||||
|
|
||||||
|
/// Checks if user is complying with the break
|
||||||
|
func checkUserCompliance()
|
||||||
|
|
||||||
|
/// Handles reminder dismissal
|
||||||
|
func handleReminderDismissed()
|
||||||
|
|
||||||
|
/// Starts test mode
|
||||||
|
func startTestMode() async
|
||||||
|
|
||||||
|
/// Stops test mode
|
||||||
|
func stopTestMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnforceModeService: EnforceModeProviding {}
|
||||||
48
Gaze/Protocols/SettingsProviding.swift
Normal file
48
Gaze/Protocols/SettingsProviding.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//
|
||||||
|
// SettingsProviding.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Protocol abstraction for SettingsManager to enable dependency injection and testing.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Protocol that defines the interface for managing application settings.
|
||||||
|
/// This abstraction allows for dependency injection and easy mocking in tests.
|
||||||
|
@MainActor
|
||||||
|
protocol SettingsProviding: AnyObject, ObservableObject {
|
||||||
|
/// The current application settings
|
||||||
|
var settings: AppSettings { get set }
|
||||||
|
|
||||||
|
/// Publisher for observing settings changes
|
||||||
|
var settingsPublisher: Published<AppSettings>.Publisher { get }
|
||||||
|
|
||||||
|
/// Retrieves the timer configuration for a specific timer type
|
||||||
|
func timerConfiguration(for type: TimerType) -> TimerConfiguration
|
||||||
|
|
||||||
|
/// Updates the timer configuration for a specific timer type
|
||||||
|
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration)
|
||||||
|
|
||||||
|
/// Returns all timer configurations
|
||||||
|
func allTimerConfigurations() -> [TimerType: TimerConfiguration]
|
||||||
|
|
||||||
|
/// Saves settings to persistent storage
|
||||||
|
func save()
|
||||||
|
|
||||||
|
/// Forces immediate save
|
||||||
|
func saveImmediately()
|
||||||
|
|
||||||
|
/// Loads settings from persistent storage
|
||||||
|
func load()
|
||||||
|
|
||||||
|
/// Resets settings to default values
|
||||||
|
func resetToDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension to provide the publisher for SettingsManager
|
||||||
|
extension SettingsManager: SettingsProviding {
|
||||||
|
var settingsPublisher: Published<AppSettings>.Publisher {
|
||||||
|
$settings
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Gaze/Protocols/SmartModeProviding.swift
Normal file
52
Gaze/Protocols/SmartModeProviding.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//
|
||||||
|
// SmartModeProviding.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Protocols for Smart Mode services (Fullscreen Detection, Idle Monitoring).
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Protocol for fullscreen detection functionality
|
||||||
|
@MainActor
|
||||||
|
protocol FullscreenDetectionProviding: AnyObject, ObservableObject {
|
||||||
|
/// Whether a fullscreen app is currently active
|
||||||
|
var isFullscreenActive: Bool { get }
|
||||||
|
|
||||||
|
/// Publisher for fullscreen state changes
|
||||||
|
var isFullscreenActivePublisher: Published<Bool>.Publisher { get }
|
||||||
|
|
||||||
|
/// Forces an immediate state update
|
||||||
|
func forceUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol for idle monitoring functionality
|
||||||
|
@MainActor
|
||||||
|
protocol IdleMonitoringProviding: AnyObject, ObservableObject {
|
||||||
|
/// Whether the user is currently idle
|
||||||
|
var isIdle: Bool { get }
|
||||||
|
|
||||||
|
/// Publisher for idle state changes
|
||||||
|
var isIdlePublisher: Published<Bool>.Publisher { get }
|
||||||
|
|
||||||
|
/// Updates the idle threshold
|
||||||
|
func updateThreshold(minutes: Int)
|
||||||
|
|
||||||
|
/// Forces an immediate state update
|
||||||
|
func forceUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions for conformance
|
||||||
|
|
||||||
|
extension FullscreenDetectionService: FullscreenDetectionProviding {
|
||||||
|
var isFullscreenActivePublisher: Published<Bool>.Publisher {
|
||||||
|
$isFullscreenActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension IdleMonitoringService: IdleMonitoringProviding {
|
||||||
|
var isIdlePublisher: Published<Bool>.Publisher {
|
||||||
|
$isIdle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,35 +11,35 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
class EnforceModeService: ObservableObject {
|
class EnforceModeService: ObservableObject {
|
||||||
static let shared = EnforceModeService()
|
static let shared = EnforceModeService()
|
||||||
|
|
||||||
@Published var isEnforceModeEnabled = false
|
@Published var isEnforceModeEnabled = false
|
||||||
@Published var isCameraActive = false
|
@Published var isCameraActive = false
|
||||||
@Published var userCompliedWithBreak = false
|
@Published var userCompliedWithBreak = false
|
||||||
@Published var isTestMode = false
|
@Published var isTestMode = false
|
||||||
|
|
||||||
private var settingsManager: SettingsManager
|
private var settingsManager: SettingsManager
|
||||||
private var eyeTrackingService: EyeTrackingService
|
private var eyeTrackingService: EyeTrackingService
|
||||||
private var timerEngine: TimerEngine?
|
private var timerEngine: TimerEngine?
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var faceDetectionTimer: Timer?
|
private var faceDetectionTimer: Timer?
|
||||||
private var lastFaceDetectionTime: Date = Date.distantPast
|
private var lastFaceDetectionTime: Date = Date.distantPast
|
||||||
private let faceDetectionTimeout: TimeInterval = 5.0 // 5 seconds to consider person lost
|
private let faceDetectionTimeout: TimeInterval = 5.0 // 5 seconds to consider person lost
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.settingsManager = SettingsManager.shared
|
self.settingsManager = SettingsManager.shared
|
||||||
self.eyeTrackingService = EyeTrackingService.shared
|
self.eyeTrackingService = EyeTrackingService.shared
|
||||||
setupObservers()
|
setupObservers()
|
||||||
initializeEnforceModeState()
|
initializeEnforceModeState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
eyeTrackingService.$userLookingAtScreen
|
eyeTrackingService.$userLookingAtScreen
|
||||||
.sink { [weak self] lookingAtScreen in
|
.sink { [weak self] lookingAtScreen in
|
||||||
self?.handleGazeChange(lookingAtScreen: lookingAtScreen)
|
self?.handleGazeChange(lookingAtScreen: lookingAtScreen)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Observe face detection changes to track person presence
|
// Observe face detection changes to track person presence
|
||||||
eyeTrackingService.$faceDetected
|
eyeTrackingService.$faceDetected
|
||||||
.sink { [weak self] faceDetected in
|
.sink { [weak self] faceDetected in
|
||||||
@@ -47,11 +47,11 @@ class EnforceModeService: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initializeEnforceModeState() {
|
private func initializeEnforceModeState() {
|
||||||
let cameraService = CameraAccessService.shared
|
let cameraService = CameraAccessService.shared
|
||||||
let settingsEnabled = settingsManager.settings.enforcementMode
|
let settingsEnabled = settingsManager.settings.enforcementMode
|
||||||
|
|
||||||
// If settings say it's enabled AND camera is authorized, mark as enabled
|
// If settings say it's enabled AND camera is authorized, mark as enabled
|
||||||
if settingsEnabled && cameraService.isCameraAuthorized {
|
if settingsEnabled && cameraService.isCameraAuthorized {
|
||||||
isEnforceModeEnabled = true
|
isEnforceModeEnabled = true
|
||||||
@@ -61,14 +61,14 @@ class EnforceModeService: ObservableObject {
|
|||||||
print("🔒 Enforce mode initialized as disabled")
|
print("🔒 Enforce mode initialized as disabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableEnforceMode() async {
|
func enableEnforceMode() async {
|
||||||
print("🔒 enableEnforceMode called")
|
print("🔒 enableEnforceMode called")
|
||||||
guard !isEnforceModeEnabled else {
|
guard !isEnforceModeEnabled else {
|
||||||
print("⚠️ Enforce mode already enabled")
|
print("⚠️ Enforce mode already enabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cameraService = CameraAccessService.shared
|
let cameraService = CameraAccessService.shared
|
||||||
if !cameraService.isCameraAuthorized {
|
if !cameraService.isCameraAuthorized {
|
||||||
do {
|
do {
|
||||||
@@ -79,33 +79,33 @@ class EnforceModeService: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard cameraService.isCameraAuthorized else {
|
guard cameraService.isCameraAuthorized else {
|
||||||
print("❌ Camera permission denied")
|
print("❌ Camera permission denied")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnforceModeEnabled = true
|
isEnforceModeEnabled = true
|
||||||
print("✓ Enforce mode enabled (camera will activate before lookaway reminders)")
|
print("✓ Enforce mode enabled (camera will activate before lookaway reminders)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func disableEnforceMode() {
|
func disableEnforceMode() {
|
||||||
guard isEnforceModeEnabled else { return }
|
guard isEnforceModeEnabled else { return }
|
||||||
|
|
||||||
stopCamera()
|
stopCamera()
|
||||||
isEnforceModeEnabled = false
|
isEnforceModeEnabled = false
|
||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
print("✓ Enforce mode disabled")
|
print("✓ Enforce mode disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setTimerEngine(_ engine: TimerEngine) {
|
func setTimerEngine(_ engine: TimerEngine) {
|
||||||
self.timerEngine = engine
|
self.timerEngine = engine
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool {
|
func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool {
|
||||||
guard isEnforceModeEnabled else { return false }
|
guard isEnforceModeEnabled else { return false }
|
||||||
guard settingsManager.settings.enforcementMode else { return false }
|
guard settingsManager.settings.enforcementMode else { return false }
|
||||||
|
|
||||||
switch timerIdentifier {
|
switch timerIdentifier {
|
||||||
case .builtIn(let type):
|
case .builtIn(let type):
|
||||||
return type == .lookAway
|
return type == .lookAway
|
||||||
@@ -113,88 +113,91 @@ class EnforceModeService: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCameraForLookawayTimer(secondsRemaining: Int) async {
|
func startCameraForLookawayTimer(secondsRemaining: Int) async {
|
||||||
guard isEnforceModeEnabled else { return }
|
guard isEnforceModeEnabled else { return }
|
||||||
guard !isCameraActive else { return }
|
guard !isCameraActive else { return }
|
||||||
|
|
||||||
print("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
|
print("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await eyeTrackingService.startEyeTracking()
|
try await eyeTrackingService.startEyeTracking()
|
||||||
isCameraActive = true
|
isCameraActive = true
|
||||||
lastFaceDetectionTime = Date() // Reset grace period
|
lastFaceDetectionTime = Date() // Reset grace period
|
||||||
startFaceDetectionTimer()
|
startFaceDetectionTimer()
|
||||||
print("✓ Camera active")
|
print("✓ Camera active")
|
||||||
} catch {
|
} catch {
|
||||||
print("⚠️ Failed to start camera: \(error.localizedDescription)")
|
print("⚠️ Failed to start camera: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopCamera() {
|
func stopCamera() {
|
||||||
guard isCameraActive else { return }
|
guard isCameraActive else { return }
|
||||||
|
|
||||||
print("👁️ Stopping camera")
|
print("👁️ Stopping camera")
|
||||||
eyeTrackingService.stopEyeTracking()
|
eyeTrackingService.stopEyeTracking()
|
||||||
isCameraActive = false
|
isCameraActive = false
|
||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
|
|
||||||
stopFaceDetectionTimer()
|
stopFaceDetectionTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkUserCompliance() {
|
func checkUserCompliance() {
|
||||||
guard isCameraActive else {
|
guard isCameraActive else {
|
||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let lookingAway = !eyeTrackingService.userLookingAtScreen
|
let lookingAway = !eyeTrackingService.userLookingAtScreen
|
||||||
userCompliedWithBreak = lookingAway
|
userCompliedWithBreak = lookingAway
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleGazeChange(lookingAtScreen: Bool) {
|
private func handleGazeChange(lookingAtScreen: Bool) {
|
||||||
guard isCameraActive else { return }
|
guard isCameraActive else { return }
|
||||||
|
|
||||||
checkUserCompliance()
|
checkUserCompliance()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFaceDetectionChange(faceDetected: Bool) {
|
private func handleFaceDetectionChange(faceDetected: Bool) {
|
||||||
// Update the last face detection time only when a face is actively detected
|
// Update the last face detection time only when a face is actively detected
|
||||||
if faceDetected {
|
if faceDetected {
|
||||||
lastFaceDetectionTime = Date()
|
lastFaceDetectionTime = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startFaceDetectionTimer() {
|
private func startFaceDetectionTimer() {
|
||||||
stopFaceDetectionTimer()
|
stopFaceDetectionTimer()
|
||||||
// Check every 1 second
|
// Check every 1 second
|
||||||
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) {
|
||||||
|
[weak self] _ in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.checkFaceDetectionTimeout()
|
self?.checkFaceDetectionTimeout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopFaceDetectionTimer() {
|
private func stopFaceDetectionTimer() {
|
||||||
faceDetectionTimer?.invalidate()
|
faceDetectionTimer?.invalidate()
|
||||||
faceDetectionTimer = nil
|
faceDetectionTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkFaceDetectionTimeout() {
|
private func checkFaceDetectionTimeout() {
|
||||||
guard isEnforceModeEnabled && isCameraActive else {
|
guard isEnforceModeEnabled && isCameraActive else {
|
||||||
stopFaceDetectionTimer()
|
stopFaceDetectionTimer()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
|
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
|
||||||
|
|
||||||
// If person has not been detected for too long, temporarily disable enforce mode
|
// If person has not been detected for too long, temporarily disable enforce mode
|
||||||
if timeSinceLastDetection > faceDetectionTimeout {
|
if timeSinceLastDetection > faceDetectionTimeout {
|
||||||
print("⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode.")
|
print(
|
||||||
|
"⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode."
|
||||||
|
)
|
||||||
disableEnforceMode()
|
disableEnforceMode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleReminderDismissed() {
|
func handleReminderDismissed() {
|
||||||
// Stop camera when reminder is dismissed, but also check if we should disable enforce mode entirely
|
// Stop camera when reminder is dismissed, but also check if we should disable enforce mode entirely
|
||||||
// This helps in case a user closes settings window while a reminder is active
|
// This helps in case a user closes settings window while a reminder is active
|
||||||
@@ -202,18 +205,18 @@ class EnforceModeService: ObservableObject {
|
|||||||
stopCamera()
|
stopCamera()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTestMode() async {
|
func startTestMode() async {
|
||||||
guard isEnforceModeEnabled else { return }
|
guard isEnforceModeEnabled else { return }
|
||||||
guard !isCameraActive else { return }
|
guard !isCameraActive else { return }
|
||||||
|
|
||||||
print("🧪 Starting test mode")
|
print("🧪 Starting test mode")
|
||||||
isTestMode = true
|
isTestMode = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await eyeTrackingService.startEyeTracking()
|
try await eyeTrackingService.startEyeTracking()
|
||||||
isCameraActive = true
|
isCameraActive = true
|
||||||
lastFaceDetectionTime = Date() // Reset grace period
|
lastFaceDetectionTime = Date() // Reset grace period
|
||||||
startFaceDetectionTimer()
|
startFaceDetectionTimer()
|
||||||
print("✓ Test mode camera active")
|
print("✓ Test mode camera active")
|
||||||
} catch {
|
} catch {
|
||||||
@@ -221,12 +224,13 @@ class EnforceModeService: ObservableObject {
|
|||||||
isTestMode = false
|
isTestMode = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopTestMode() {
|
func stopTestMode() {
|
||||||
guard isTestMode else { return }
|
guard isTestMode else { return }
|
||||||
|
|
||||||
print("🧪 Stopping test mode")
|
print("🧪 Stopping test mode")
|
||||||
stopCamera()
|
stopCamera()
|
||||||
isTestMode = false
|
isTestMode = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Combine
|
import Combine
|
||||||
import Vision
|
import Vision
|
||||||
|
import simd
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class EyeTrackingService: NSObject, ObservableObject {
|
class EyeTrackingService: NSObject, ObservableObject {
|
||||||
@@ -18,6 +19,16 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
@Published var userLookingAtScreen = true
|
@Published var userLookingAtScreen = true
|
||||||
@Published var faceDetected = false
|
@Published var faceDetected = false
|
||||||
|
|
||||||
|
// Debug properties for UI display
|
||||||
|
@Published var debugLeftPupilRatio: Double?
|
||||||
|
@Published var debugRightPupilRatio: Double?
|
||||||
|
@Published var debugYaw: Double?
|
||||||
|
@Published var debugPitch: Double?
|
||||||
|
@Published var enableDebugLogging: Bool = false
|
||||||
|
|
||||||
|
// Throttle for debug logging
|
||||||
|
private var lastDebugLogTime: Date = .distantPast
|
||||||
|
|
||||||
private var captureSession: AVCaptureSession?
|
private var captureSession: AVCaptureSession?
|
||||||
private var videoOutput: AVCaptureVideoDataOutput?
|
private var videoOutput: AVCaptureVideoDataOutput?
|
||||||
private let videoDataOutputQueue = DispatchQueue(
|
private let videoDataOutputQueue = DispatchQueue(
|
||||||
@@ -116,7 +127,7 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
self.videoOutput = output
|
self.videoOutput = output
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processFaceObservations(_ observations: [VNFaceObservation]?) {
|
private func processFaceObservations(_ observations: [VNFaceObservation]?, imageSize: CGSize) {
|
||||||
guard let observations = observations, !observations.isEmpty else {
|
guard let observations = observations, !observations.isEmpty else {
|
||||||
faceDetected = false
|
faceDetected = false
|
||||||
userLookingAtScreen = false
|
userLookingAtScreen = false
|
||||||
@@ -126,10 +137,26 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
faceDetected = true
|
faceDetected = true
|
||||||
let face = observations.first!
|
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 {
|
guard let landmarks = face.landmarks else {
|
||||||
|
if enableDebugLogging {
|
||||||
|
print("👁️ No landmarks available")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if enableDebugLogging {
|
||||||
|
print(
|
||||||
|
"👁️ Landmarks - leftEye: \(landmarks.leftEye != nil), rightEye: \(landmarks.rightEye != nil), leftPupil: \(landmarks.leftPupil != nil), rightPupil: \(landmarks.rightPupil != nil)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Check eye closure
|
// Check eye closure
|
||||||
if let leftEye = landmarks.leftEye,
|
if let leftEye = landmarks.leftEye,
|
||||||
let rightEye = landmarks.rightEye
|
let rightEye = landmarks.rightEye
|
||||||
@@ -140,13 +167,25 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check gaze direction
|
// Check gaze direction
|
||||||
let lookingAway = detectLookingAway(face: face, landmarks: landmarks, shouldLog: false)
|
let lookingAway = detectLookingAway(
|
||||||
|
face: face,
|
||||||
|
landmarks: landmarks,
|
||||||
|
imageSize: imageSize,
|
||||||
|
shouldLog: enableDebugLogging
|
||||||
|
)
|
||||||
userLookingAtScreen = !lookingAway
|
userLookingAtScreen = !lookingAway
|
||||||
}
|
}
|
||||||
|
|
||||||
private func detectEyesClosed(
|
private func detectEyesClosed(
|
||||||
leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D, shouldLog: Bool
|
leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D, shouldLog: Bool
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
let constants = EyeTrackingConstants.shared
|
||||||
|
|
||||||
|
// If eye closure detection is disabled, always return false (eyes not closed)
|
||||||
|
guard constants.eyeClosedEnabled else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else {
|
guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -154,7 +193,7 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
let leftEyeHeight = calculateEyeHeight(leftEye, shouldLog: shouldLog)
|
let leftEyeHeight = calculateEyeHeight(leftEye, shouldLog: shouldLog)
|
||||||
let rightEyeHeight = calculateEyeHeight(rightEye, shouldLog: shouldLog)
|
let rightEyeHeight = calculateEyeHeight(rightEye, shouldLog: shouldLog)
|
||||||
|
|
||||||
let closedThreshold = EyeTrackingConstants.eyeClosedThreshold
|
let closedThreshold = constants.eyeClosedThreshold
|
||||||
|
|
||||||
let isClosed = leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold
|
let isClosed = leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold
|
||||||
|
|
||||||
@@ -175,22 +214,57 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func detectLookingAway(
|
private func detectLookingAway(
|
||||||
face: VNFaceObservation, landmarks: VNFaceLandmarks2D, shouldLog: Bool
|
face: VNFaceObservation, landmarks: VNFaceLandmarks2D, imageSize: CGSize, shouldLog: Bool
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
let constants = EyeTrackingConstants.shared
|
||||||
|
|
||||||
// 1. Face Pose Check (Yaw & Pitch)
|
// 1. Face Pose Check (Yaw & Pitch)
|
||||||
let yaw = face.yaw?.doubleValue ?? 0.0
|
let yaw = face.yaw?.doubleValue ?? 0.0
|
||||||
let pitch = face.pitch?.doubleValue ?? 0.0
|
let pitch = face.pitch?.doubleValue ?? 0.0
|
||||||
|
let roll = face.roll?.doubleValue ?? 0.0
|
||||||
|
|
||||||
let yawThreshold = EyeTrackingConstants.yawThreshold
|
// Debug logging
|
||||||
// Pitch check:
|
if shouldLog {
|
||||||
// - Camera at top = looking at screen is negative pitch
|
print("👁️ Face Pose - Yaw: \(yaw), Pitch: \(pitch), Roll: \(roll)")
|
||||||
// - Looking above screen (straight ahead) is ~0 or positive -> Look Away
|
print(
|
||||||
// - Looking at keyboard/lap is very negative -> Look Away
|
"👁️ Face available data - hasYaw: \(face.yaw != nil), hasPitch: \(face.pitch != nil), hasRoll: \(face.roll != nil)"
|
||||||
let pitchLookingAway =
|
)
|
||||||
pitch > EyeTrackingConstants.pitchUpThreshold
|
}
|
||||||
|| pitch < EyeTrackingConstants.pitchDownThreshold
|
|
||||||
|
|
||||||
let poseLookingAway = abs(yaw) > yawThreshold || pitchLookingAway
|
// 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 constants.yawEnabled {
|
||||||
|
let yawThreshold = constants.yawThreshold
|
||||||
|
if abs(yaw) > yawThreshold {
|
||||||
|
poseLookingAway = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pitch if either threshold is enabled
|
||||||
|
if !poseLookingAway {
|
||||||
|
var pitchLookingAway = false
|
||||||
|
|
||||||
|
if constants.pitchUpEnabled && pitch > constants.pitchUpThreshold {
|
||||||
|
pitchLookingAway = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if constants.pitchDownEnabled && pitch < constants.pitchDownThreshold {
|
||||||
|
pitchLookingAway = true
|
||||||
|
}
|
||||||
|
|
||||||
|
poseLookingAway = pitchLookingAway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Eye Gaze Check (Pupil Position)
|
// 2. Eye Gaze Check (Pupil Position)
|
||||||
var eyesLookingAway = false
|
var eyesLookingAway = false
|
||||||
@@ -200,22 +274,92 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
let leftPupil = landmarks.leftPupil,
|
let leftPupil = landmarks.leftPupil,
|
||||||
let rightPupil = landmarks.rightPupil
|
let rightPupil = landmarks.rightPupil
|
||||||
{
|
{
|
||||||
|
|
||||||
|
// NEW: Use inter-eye distance method
|
||||||
|
let gazeOffsets = calculateGazeUsingInterEyeDistance(
|
||||||
|
leftEye: leftEye,
|
||||||
|
rightEye: rightEye,
|
||||||
|
leftPupil: leftPupil,
|
||||||
|
rightPupil: rightPupil,
|
||||||
|
imageSize: imageSize,
|
||||||
|
faceBoundingBox: face.boundingBox
|
||||||
|
)
|
||||||
|
|
||||||
let leftRatio = calculatePupilHorizontalRatio(eye: leftEye, pupil: leftPupil)
|
let leftRatio = calculatePupilHorizontalRatio(
|
||||||
let rightRatio = calculatePupilHorizontalRatio(eye: rightEye, pupil: rightPupil)
|
eye: leftEye,
|
||||||
|
pupil: leftPupil,
|
||||||
|
imageSize: imageSize,
|
||||||
|
faceBoundingBox: face.boundingBox
|
||||||
|
)
|
||||||
|
let rightRatio = calculatePupilHorizontalRatio(
|
||||||
|
eye: rightEye,
|
||||||
|
pupil: rightPupil,
|
||||||
|
imageSize: imageSize,
|
||||||
|
faceBoundingBox: face.boundingBox
|
||||||
|
)
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
if shouldLog {
|
||||||
|
print(
|
||||||
|
"👁️ Pupil Ratios (OLD METHOD) - Left: \(String(format: "%.3f", leftRatio)), Right: \(String(format: "%.3f", rightRatio))"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"👁️ Gaze Offsets (NEW METHOD) - Left: \(String(format: "%.3f", gazeOffsets.leftGaze)), Right: \(String(format: "%.3f", gazeOffsets.rightGaze))"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"👁️ Thresholds - Min: \(constants.minPupilRatio), Max: \(constants.maxPupilRatio)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update debug values
|
||||||
|
Task { @MainActor in
|
||||||
|
debugLeftPupilRatio = leftRatio
|
||||||
|
debugRightPupilRatio = rightRatio
|
||||||
|
}
|
||||||
|
|
||||||
// Normal range for "looking center" is roughly 0.3 to 0.7
|
// Normal range for "looking center" is roughly 0.3 to 0.7
|
||||||
// (0.0 = extreme right, 1.0 = extreme left relative to face)
|
// (0.0 = extreme right, 1.0 = extreme left relative to face)
|
||||||
// Note: Camera is mirrored, so logic might be inverted
|
// Note: Camera is mirrored, so logic might be inverted
|
||||||
|
|
||||||
let minRatio = EyeTrackingConstants.minPupilRatio
|
var leftLookingAway = false
|
||||||
let maxRatio = EyeTrackingConstants.maxPupilRatio
|
var rightLookingAway = false
|
||||||
|
|
||||||
let leftLookingAway = leftRatio < minRatio || leftRatio > maxRatio
|
// Check min pupil ratio if enabled
|
||||||
let rightLookingAway = rightRatio < minRatio || rightRatio > maxRatio
|
/*if constants.minPupilEnabled {*/
|
||||||
|
/*let minRatio = constants.minPupilRatio*/
|
||||||
|
/*if leftRatio < minRatio {*/
|
||||||
|
/*leftLookingAway = true*/
|
||||||
|
/*}*/
|
||||||
|
/*if rightRatio < minRatio {*/
|
||||||
|
/*rightLookingAway = true*/
|
||||||
|
/*}*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
// Consider looking away if BOTH eyes are off-center
|
/*// Check max pupil ratio if enabled*/
|
||||||
eyesLookingAway = leftLookingAway && rightLookingAway
|
/*if constants.maxPupilEnabled {*/
|
||||||
|
/*let maxRatio = constants.maxPupilRatio*/
|
||||||
|
/*if leftRatio > maxRatio {*/
|
||||||
|
/*leftLookingAway = true*/
|
||||||
|
/*}*/
|
||||||
|
/*if rightRatio > maxRatio {*/
|
||||||
|
/*rightLookingAway = true*/
|
||||||
|
/*}*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
// Consider looking away if EITHER eye is off-center
|
||||||
|
// Changed from AND to OR logic because requiring both eyes makes detection too restrictive
|
||||||
|
// This is more sensitive but also more reliable for detecting actual looking away
|
||||||
|
eyesLookingAway = leftLookingAway || rightLookingAway
|
||||||
|
|
||||||
|
if shouldLog {
|
||||||
|
print(
|
||||||
|
"👁️ Looking Away - Left: \(leftLookingAway), Right: \(rightLookingAway), Either: \(eyesLookingAway)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if shouldLog {
|
||||||
|
print("👁️ Missing pupil or eye landmarks!")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let isLookingAway = poseLookingAway || eyesLookingAway
|
let isLookingAway = poseLookingAway || eyesLookingAway
|
||||||
@@ -224,34 +368,228 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func calculatePupilHorizontalRatio(
|
private func calculatePupilHorizontalRatio(
|
||||||
eye: VNFaceLandmarkRegion2D, pupil: VNFaceLandmarkRegion2D
|
eye: VNFaceLandmarkRegion2D,
|
||||||
|
pupil: VNFaceLandmarkRegion2D,
|
||||||
|
imageSize: CGSize,
|
||||||
|
faceBoundingBox: CGRect
|
||||||
) -> Double {
|
) -> Double {
|
||||||
|
// Use normalizedPoints which are already normalized to face bounding box
|
||||||
let eyePoints = eye.normalizedPoints
|
let eyePoints = eye.normalizedPoints
|
||||||
let pupilPoints = pupil.normalizedPoints
|
let pupilPoints = pupil.normalizedPoints
|
||||||
|
|
||||||
|
// Throttle debug logging to every 0.5 seconds
|
||||||
|
let now = Date()
|
||||||
|
let shouldLog = now.timeIntervalSince(lastDebugLogTime) >= 0.5
|
||||||
|
|
||||||
|
if shouldLog {
|
||||||
|
lastDebugLogTime = now
|
||||||
|
|
||||||
|
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
print("📊 EYE TRACKING DEBUG DATA")
|
||||||
|
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
|
||||||
|
print("\n🖼️ IMAGE SIZE:")
|
||||||
|
print(" Width: \(imageSize.width), Height: \(imageSize.height)")
|
||||||
|
|
||||||
|
print("\n📦 FACE BOUNDING BOX (normalized):")
|
||||||
|
print(" Origin: (\(faceBoundingBox.origin.x), \(faceBoundingBox.origin.y))")
|
||||||
|
print(" Size: (\(faceBoundingBox.size.width), \(faceBoundingBox.size.height))")
|
||||||
|
|
||||||
|
print("\n👁️ EYE LANDMARK POINTS (normalized to face bounding box - from Vision):")
|
||||||
|
print(" Count: \(eyePoints.count)")
|
||||||
|
let eyeMinX = eyePoints.min(by: { $0.x < $1.x })?.x ?? 0
|
||||||
|
let eyeMaxX = eyePoints.max(by: { $0.x < $1.x })?.x ?? 0
|
||||||
|
for (index, point) in eyePoints.enumerated() {
|
||||||
|
var marker = ""
|
||||||
|
if abs(point.x - eyeMinX) < 0.0001 {
|
||||||
|
marker = " ← LEFTMOST (inner corner)"
|
||||||
|
} else if abs(point.x - eyeMaxX) < 0.0001 {
|
||||||
|
marker = " ← RIGHTMOST (outer corner)"
|
||||||
|
}
|
||||||
|
if index == 0 {
|
||||||
|
marker += " [FIRST]"
|
||||||
|
} else if index == eyePoints.count - 1 {
|
||||||
|
marker += " [LAST]"
|
||||||
|
}
|
||||||
|
print(
|
||||||
|
" [\(index)]: (\(String(format: "%.4f", point.x)), \(String(format: "%.4f", point.y)))\(marker)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n👁️ PUPIL LANDMARK POINTS (normalized to face bounding box - from Vision):")
|
||||||
|
print(" Count: \(pupilPoints.count)")
|
||||||
|
for (index, point) in pupilPoints.enumerated() {
|
||||||
|
print(
|
||||||
|
" [\(index)]: (\(String(format: "%.4f", point.x)), \(String(format: "%.4f", point.y)))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let minPoint = eyePoints.min(by: { $0.x < $1.x }),
|
||||||
|
let maxPoint = eyePoints.max(by: { $0.x < $1.x })
|
||||||
|
{
|
||||||
|
let eyeMinX = minPoint.x
|
||||||
|
let eyeMaxX = maxPoint.x
|
||||||
|
let eyeWidth = eyeMaxX - eyeMinX
|
||||||
|
let pupilCenterX = pupilPoints.map { $0.x }.reduce(0, +) / Double(pupilPoints.count)
|
||||||
|
let ratio = (pupilCenterX - eyeMinX) / eyeWidth
|
||||||
|
|
||||||
|
print("\n📏 CALCULATIONS:")
|
||||||
|
print(" Eye MinX: \(String(format: "%.4f", eyeMinX))")
|
||||||
|
print(" Eye MaxX: \(String(format: "%.4f", eyeMaxX))")
|
||||||
|
print(" Eye Width: \(String(format: "%.4f", eyeWidth))")
|
||||||
|
|
||||||
|
// Analyze different point pairs to find better eye width
|
||||||
|
if eyePoints.count >= 6 {
|
||||||
|
let cornerWidth = eyePoints[5].x - eyePoints[0].x
|
||||||
|
print(" Corner-to-Corner Width [0→5]: \(String(format: "%.4f", cornerWidth))")
|
||||||
|
|
||||||
|
// Try middle points too
|
||||||
|
if eyePoints.count >= 4 {
|
||||||
|
let midWidth = eyePoints[3].x - eyePoints[0].x
|
||||||
|
print(" Point [0→3] Width: \(String(format: "%.4f", midWidth))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(" Pupil Center X: \(String(format: "%.4f", pupilCenterX))")
|
||||||
|
print(" Pupil Min X: \(String(format: "%.4f", pupilPoints.min(by: { $0.x < $1.x })?.x ?? 0))")
|
||||||
|
print(" Pupil Max X: \(String(format: "%.4f", pupilPoints.max(by: { $0.x < $1.x })?.x ?? 0))")
|
||||||
|
print(" Final Ratio (current method): \(String(format: "%.4f", ratio))")
|
||||||
|
|
||||||
|
// Calculate alternate ratios
|
||||||
|
if eyePoints.count >= 6 {
|
||||||
|
let cornerWidth = eyePoints[5].x - eyePoints[0].x
|
||||||
|
if cornerWidth > 0 {
|
||||||
|
let cornerRatio = (pupilCenterX - eyePoints[0].x) / cornerWidth
|
||||||
|
print(" Alternate Ratio (using corners [0→5]): \(String(format: "%.4f", cornerRatio))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||||
|
}
|
||||||
|
|
||||||
guard !eyePoints.isEmpty, !pupilPoints.isEmpty else { return 0.5 }
|
guard !eyePoints.isEmpty, !pupilPoints.isEmpty else { return 0.5 }
|
||||||
|
|
||||||
// Get eye horizontal bounds
|
guard let minPoint = eyePoints.min(by: { $0.x < $1.x }),
|
||||||
let eyeMinX = eyePoints.map { $0.x }.min() ?? 0
|
let maxPoint = eyePoints.max(by: { $0.x < $1.x })
|
||||||
let eyeMaxX = eyePoints.map { $0.x }.max() ?? 0
|
else {
|
||||||
|
return 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
let eyeMinX = minPoint.x
|
||||||
|
let eyeMaxX = maxPoint.x
|
||||||
let eyeWidth = eyeMaxX - eyeMinX
|
let eyeWidth = eyeMaxX - eyeMinX
|
||||||
|
|
||||||
guard eyeWidth > 0 else { return 0.5 }
|
guard eyeWidth > 0 else { return 0.5 }
|
||||||
|
|
||||||
// Get pupil center X
|
|
||||||
let pupilCenterX = pupilPoints.map { $0.x }.reduce(0, +) / Double(pupilPoints.count)
|
let pupilCenterX = pupilPoints.map { $0.x }.reduce(0, +) / Double(pupilPoints.count)
|
||||||
|
|
||||||
// Calculate ratio (0.0 to 1.0)
|
// Calculate ratio (0.0 to 1.0) - already normalized to face bounding box by Vision
|
||||||
// 0.0 = Right side of eye (camera view)
|
|
||||||
// 1.0 = Left side of eye (camera view)
|
|
||||||
let ratio = (pupilCenterX - eyeMinX) / eyeWidth
|
let ratio = (pupilCenterX - eyeMinX) / eyeWidth
|
||||||
|
|
||||||
return ratio
|
return ratio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NEW APPROACH: Calculate gaze using inter-eye distance as reference
|
||||||
|
/// This works around Vision's limitation that eye landmarks only track the iris, not true eye corners
|
||||||
|
private func calculateGazeUsingInterEyeDistance(
|
||||||
|
leftEye: VNFaceLandmarkRegion2D,
|
||||||
|
rightEye: VNFaceLandmarkRegion2D,
|
||||||
|
leftPupil: VNFaceLandmarkRegion2D,
|
||||||
|
rightPupil: VNFaceLandmarkRegion2D,
|
||||||
|
imageSize: CGSize,
|
||||||
|
faceBoundingBox: CGRect
|
||||||
|
) -> (leftGaze: Double, rightGaze: Double) {
|
||||||
|
|
||||||
|
// CRITICAL: Convert from face-normalized coordinates to image coordinates
|
||||||
|
// normalizedPoints are relative to face bounding box, not stable for gaze tracking
|
||||||
|
|
||||||
|
// Helper to convert face-normalized point to image coordinates
|
||||||
|
func toImageCoords(_ point: CGPoint) -> CGPoint {
|
||||||
|
// Face bounding box origin is in Vision coordinates (bottom-left origin)
|
||||||
|
let imageX = faceBoundingBox.origin.x + point.x * faceBoundingBox.width
|
||||||
|
let imageY = faceBoundingBox.origin.y + point.y * faceBoundingBox.height
|
||||||
|
return CGPoint(x: imageX, y: imageY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert all points to image space
|
||||||
|
let leftEyePointsImg = leftEye.normalizedPoints.map { toImageCoords($0) }
|
||||||
|
let rightEyePointsImg = rightEye.normalizedPoints.map { toImageCoords($0) }
|
||||||
|
let leftPupilPointsImg = leftPupil.normalizedPoints.map { toImageCoords($0) }
|
||||||
|
let rightPupilPointsImg = rightPupil.normalizedPoints.map { toImageCoords($0) }
|
||||||
|
|
||||||
|
// Calculate eye centers (average of all iris boundary points)
|
||||||
|
let leftEyeCenterX = leftEyePointsImg.map { $0.x }.reduce(0, +) / Double(leftEyePointsImg.count)
|
||||||
|
let rightEyeCenterX = rightEyePointsImg.map { $0.x }.reduce(0, +) / Double(rightEyePointsImg.count)
|
||||||
|
|
||||||
|
// Calculate pupil centers
|
||||||
|
let leftPupilX = leftPupilPointsImg.map { $0.x }.reduce(0, +) / Double(leftPupilPointsImg.count)
|
||||||
|
let rightPupilX = rightPupilPointsImg.map { $0.x }.reduce(0, +) / Double(rightPupilPointsImg.count)
|
||||||
|
|
||||||
|
// Inter-eye distance (the distance between eye centers) - should be stable now
|
||||||
|
let interEyeDistance = abs(rightEyeCenterX - leftEyeCenterX)
|
||||||
|
|
||||||
|
// Estimate iris width as a fraction of inter-eye distance
|
||||||
|
// Typical human: inter-pupil distance ~63mm, iris width ~12mm → ratio ~1/5
|
||||||
|
let irisWidth = interEyeDistance / 5.0
|
||||||
|
|
||||||
|
// Calculate gaze offset for each eye (positive = looking right, negative = looking left)
|
||||||
|
let leftGazeOffset = (leftPupilX - leftEyeCenterX) / irisWidth
|
||||||
|
let rightGazeOffset = (rightPupilX - rightEyeCenterX) / irisWidth
|
||||||
|
|
||||||
|
// Throttle debug logging
|
||||||
|
let now = Date()
|
||||||
|
let shouldLog = now.timeIntervalSince(lastDebugLogTime) >= 0.5
|
||||||
|
|
||||||
|
if shouldLog {
|
||||||
|
lastDebugLogTime = now
|
||||||
|
|
||||||
|
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
print("📊 INTER-EYE DISTANCE GAZE (IMAGE COORDS)")
|
||||||
|
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
|
||||||
|
print("\n🖼️ IMAGE SPACE:")
|
||||||
|
print(" Image Size: \(Int(imageSize.width)) x \(Int(imageSize.height))")
|
||||||
|
print(" Face Box: x=\(String(format: "%.3f", faceBoundingBox.origin.x)) w=\(String(format: "%.3f", faceBoundingBox.width))")
|
||||||
|
|
||||||
|
print("\n👁️ EYE CENTERS (image coords):")
|
||||||
|
print(" Left Eye Center X: \(String(format: "%.4f", leftEyeCenterX)) (\(Int(leftEyeCenterX * imageSize.width))px)")
|
||||||
|
print(" Right Eye Center X: \(String(format: "%.4f", rightEyeCenterX)) (\(Int(rightEyeCenterX * imageSize.width))px)")
|
||||||
|
print(" Inter-Eye Distance: \(String(format: "%.4f", interEyeDistance)) (\(Int(interEyeDistance * imageSize.width))px)")
|
||||||
|
print(" Estimated Iris Width: \(String(format: "%.4f", irisWidth)) (\(Int(irisWidth * imageSize.width))px)")
|
||||||
|
|
||||||
|
print("\n👁️ PUPIL POSITIONS (image coords):")
|
||||||
|
print(" Left Pupil X: \(String(format: "%.4f", leftPupilX)) (\(Int(leftPupilX * imageSize.width))px)")
|
||||||
|
print(" Right Pupil X: \(String(format: "%.4f", rightPupilX)) (\(Int(rightPupilX * imageSize.width))px)")
|
||||||
|
|
||||||
|
print("\n📏 PUPIL OFFSETS FROM EYE CENTER:")
|
||||||
|
print(" Left Offset: \(String(format: "%.4f", leftPupilX - leftEyeCenterX)) (\(Int((leftPupilX - leftEyeCenterX) * imageSize.width))px)")
|
||||||
|
print(" Right Offset: \(String(format: "%.4f", rightPupilX - rightEyeCenterX)) (\(Int((rightPupilX - rightEyeCenterX) * imageSize.width))px)")
|
||||||
|
|
||||||
|
print("\n📏 GAZE OFFSETS (normalized to iris width):")
|
||||||
|
print(" Left Gaze Offset: \(String(format: "%.4f", leftGazeOffset)) (0=center, +right, -left)")
|
||||||
|
print(" Right Gaze Offset: \(String(format: "%.4f", rightGazeOffset)) (0=center, +right, -left)")
|
||||||
|
print(" Average Gaze: \(String(format: "%.4f", (leftGazeOffset + rightGazeOffset) / 2))")
|
||||||
|
|
||||||
|
// Interpretation
|
||||||
|
let avgGaze = (leftGazeOffset + rightGazeOffset) / 2
|
||||||
|
var interpretation = ""
|
||||||
|
if avgGaze < -0.5 {
|
||||||
|
interpretation = "Looking LEFT"
|
||||||
|
} else if avgGaze > 0.5 {
|
||||||
|
interpretation = "Looking RIGHT"
|
||||||
|
} else {
|
||||||
|
interpretation = "Looking CENTER"
|
||||||
|
}
|
||||||
|
print(" Interpretation: \(interpretation)")
|
||||||
|
|
||||||
|
print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (leftGazeOffset, rightGazeOffset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate
|
|
||||||
|
|
||||||
extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate {
|
extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
nonisolated func captureOutput(
|
nonisolated func captureOutput(
|
||||||
_ output: AVCaptureOutput,
|
_ output: AVCaptureOutput,
|
||||||
@@ -270,13 +608,27 @@ extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let size = CGSize(
|
||||||
|
width: CVPixelBufferGetWidth(pixelBuffer),
|
||||||
|
height: CVPixelBufferGetHeight(pixelBuffer)
|
||||||
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.processFaceObservations(request.results as? [VNFaceObservation])
|
self.processFaceObservations(
|
||||||
|
request.results as? [VNFaceObservation],
|
||||||
|
imageSize: size
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use revision 3 which includes more detailed landmarks including pupils
|
||||||
request.revision = VNDetectFaceLandmarksRequestRevision3
|
request.revision = VNDetectFaceLandmarksRequestRevision3
|
||||||
|
|
||||||
|
// Enable constellation points which may help with pose estimation
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
request.constellation = .constellation76Points
|
||||||
|
}
|
||||||
|
|
||||||
let imageRequestHandler = VNImageRequestHandler(
|
let imageRequestHandler = VNImageRequestHandler(
|
||||||
cvPixelBuffer: pixelBuffer,
|
cvPixelBuffer: pixelBuffer,
|
||||||
orientation: .leftMirrored,
|
orientation: .leftMirrored,
|
||||||
|
|||||||
266
Gaze/Services/PupilDetector.swift
Normal file
266
Gaze/Services/PupilDetector.swift
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
//
|
||||||
|
// PupilDetector.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/15/26.
|
||||||
|
//
|
||||||
|
// Pixel-based pupil detection translated from Python GazeTracking library
|
||||||
|
// Original: https://github.com/antoinelame/GazeTracking
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreImage
|
||||||
|
import Vision
|
||||||
|
import Accelerate
|
||||||
|
|
||||||
|
struct PupilPosition {
|
||||||
|
let x: CGFloat
|
||||||
|
let y: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EyeRegion {
|
||||||
|
let frame: CGRect // Bounding box of the eye in image coordinates
|
||||||
|
let center: CGPoint // Center point of the eye region
|
||||||
|
}
|
||||||
|
|
||||||
|
class PupilDetector {
|
||||||
|
|
||||||
|
/// Detects pupil position within an isolated eye region using pixel-based analysis
|
||||||
|
/// - Parameters:
|
||||||
|
/// - pixelBuffer: The camera frame pixel buffer
|
||||||
|
/// - eyeLandmarks: Vision eye landmarks (6 points around iris)
|
||||||
|
/// - faceBoundingBox: Face bounding box from Vision
|
||||||
|
/// - imageSize: Size of the camera frame
|
||||||
|
/// - Returns: Pupil position relative to eye region, or nil if detection fails
|
||||||
|
static func detectPupil(
|
||||||
|
in pixelBuffer: CVPixelBuffer,
|
||||||
|
eyeLandmarks: VNFaceLandmarkRegion2D,
|
||||||
|
faceBoundingBox: CGRect,
|
||||||
|
imageSize: CGSize
|
||||||
|
) -> (pupilPosition: PupilPosition, eyeRegion: EyeRegion)? {
|
||||||
|
|
||||||
|
// Step 1: Convert Vision landmarks to pixel coordinates
|
||||||
|
let eyePoints = landmarksToPixelCoordinates(
|
||||||
|
landmarks: eyeLandmarks,
|
||||||
|
faceBoundingBox: faceBoundingBox,
|
||||||
|
imageSize: imageSize
|
||||||
|
)
|
||||||
|
|
||||||
|
guard eyePoints.count >= 6 else { return nil }
|
||||||
|
|
||||||
|
// Step 2: Create eye region bounding box
|
||||||
|
guard let eyeRegion = createEyeRegion(from: eyePoints, imageSize: imageSize) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Extract and process eye region from pixel buffer
|
||||||
|
guard let eyeImage = extractEyeRegion(
|
||||||
|
from: pixelBuffer,
|
||||||
|
region: eyeRegion.frame,
|
||||||
|
mask: eyePoints
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Process image to isolate pupil (bilateral filter + threshold)
|
||||||
|
guard let processedImage = processEyeImage(eyeImage) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Find pupil using contour detection
|
||||||
|
guard let pupilPosition = findPupilCentroid(in: processedImage) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (pupilPosition, eyeRegion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step 1: Convert Landmarks to Pixel Coordinates
|
||||||
|
|
||||||
|
private static func landmarksToPixelCoordinates(
|
||||||
|
landmarks: VNFaceLandmarkRegion2D,
|
||||||
|
faceBoundingBox: CGRect,
|
||||||
|
imageSize: CGSize
|
||||||
|
) -> [CGPoint] {
|
||||||
|
return landmarks.normalizedPoints.map { point in
|
||||||
|
// Vision coordinates are normalized to face bounding box
|
||||||
|
let imageX = (faceBoundingBox.origin.x + point.x * faceBoundingBox.width) * imageSize.width
|
||||||
|
let imageY = (faceBoundingBox.origin.y + point.y * faceBoundingBox.height) * imageSize.height
|
||||||
|
return CGPoint(x: imageX, y: imageY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step 2: Create Eye Region
|
||||||
|
|
||||||
|
private static func createEyeRegion(from points: [CGPoint], imageSize: CGSize) -> EyeRegion? {
|
||||||
|
guard !points.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let margin: CGFloat = 5
|
||||||
|
let minX = points.map { $0.x }.min()! - margin
|
||||||
|
let maxX = points.map { $0.x }.max()! + margin
|
||||||
|
let minY = points.map { $0.y }.min()! - margin
|
||||||
|
let maxY = points.map { $0.y }.max()! + margin
|
||||||
|
|
||||||
|
// Clamp to image bounds
|
||||||
|
let clampedMinX = max(0, minX)
|
||||||
|
let clampedMaxX = min(imageSize.width, maxX)
|
||||||
|
let clampedMinY = max(0, minY)
|
||||||
|
let clampedMaxY = min(imageSize.height, maxY)
|
||||||
|
|
||||||
|
let frame = CGRect(
|
||||||
|
x: clampedMinX,
|
||||||
|
y: clampedMinY,
|
||||||
|
width: clampedMaxX - clampedMinX,
|
||||||
|
height: clampedMaxY - clampedMinY
|
||||||
|
)
|
||||||
|
|
||||||
|
let center = CGPoint(
|
||||||
|
x: frame.width / 2,
|
||||||
|
y: frame.height / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
return EyeRegion(frame: frame, center: center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step 3: Extract Eye Region
|
||||||
|
|
||||||
|
private static func extractEyeRegion(
|
||||||
|
from pixelBuffer: CVPixelBuffer,
|
||||||
|
region: CGRect,
|
||||||
|
mask: [CGPoint]
|
||||||
|
) -> CIImage? {
|
||||||
|
|
||||||
|
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
|
||||||
|
|
||||||
|
// Convert to grayscale
|
||||||
|
let grayscaleImage = ciImage.applyingFilter("CIPhotoEffectNoir")
|
||||||
|
|
||||||
|
// Crop to eye region
|
||||||
|
let croppedImage = grayscaleImage.cropped(to: region)
|
||||||
|
|
||||||
|
return croppedImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step 4: Process Eye Image
|
||||||
|
|
||||||
|
private static func processEyeImage(_ image: CIImage) -> CIImage? {
|
||||||
|
// Apply bilateral filter (preserves edges while smoothing)
|
||||||
|
// CIBilateralFilter approximation: use CIMedianFilter + morphology
|
||||||
|
var processed = image
|
||||||
|
|
||||||
|
// 1. Median filter (reduces noise while preserving edges)
|
||||||
|
processed = processed.applyingFilter("CIMedianFilter")
|
||||||
|
|
||||||
|
// 2. Morphological erosion (makes dark regions larger - approximates cv2.erode)
|
||||||
|
// Use CIMorphologyMinimum with small radius
|
||||||
|
processed = processed.applyingFilter("CIMorphologyMinimum", parameters: [
|
||||||
|
kCIInputRadiusKey: 2.0
|
||||||
|
])
|
||||||
|
|
||||||
|
// 3. Threshold to binary (black/white)
|
||||||
|
// Use CIColorControls to increase contrast, then threshold
|
||||||
|
processed = processed.applyingFilter("CIColorControls", parameters: [
|
||||||
|
kCIInputContrastKey: 2.0,
|
||||||
|
kCIInputBrightnessKey: -0.3
|
||||||
|
])
|
||||||
|
|
||||||
|
// Apply color threshold to make it binary
|
||||||
|
processed = processed.applyingFilter("CIColorThreshold", parameters: [
|
||||||
|
"inputThreshold": 0.5
|
||||||
|
])
|
||||||
|
|
||||||
|
return processed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step 5: Find Pupil Centroid
|
||||||
|
|
||||||
|
private static func findPupilCentroid(in image: CIImage) -> PupilPosition? {
|
||||||
|
let context = CIContext()
|
||||||
|
|
||||||
|
// Convert CIImage to CGImage for contour detection
|
||||||
|
guard let cgImage = context.createCGImage(image, from: image.extent) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to vImage buffer for processing
|
||||||
|
guard let (width, height, data) = cgImageToGrayscaleData(cgImage) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find connected components (contours)
|
||||||
|
guard let (centroidX, centroidY) = findLargestDarkRegionCentroid(
|
||||||
|
data: data,
|
||||||
|
width: width,
|
||||||
|
height: height
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return PupilPosition(x: CGFloat(centroidX), y: CGFloat(centroidY))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper: Convert CGImage to Grayscale Data
|
||||||
|
|
||||||
|
private static func cgImageToGrayscaleData(_ cgImage: CGImage) -> (width: Int, height: Int, data: [UInt8])? {
|
||||||
|
let width = cgImage.width
|
||||||
|
let height = cgImage.height
|
||||||
|
|
||||||
|
var data = [UInt8](repeating: 0, count: width * height)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: &data,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: width,
|
||||||
|
space: CGColorSpaceCreateDeviceGray(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
|
||||||
|
return (width, height, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper: Find Centroid of Largest Dark Region
|
||||||
|
|
||||||
|
private static func findLargestDarkRegionCentroid(
|
||||||
|
data: [UInt8],
|
||||||
|
width: Int,
|
||||||
|
height: Int
|
||||||
|
) -> (x: Double, y: Double)? {
|
||||||
|
|
||||||
|
// Calculate image moments to find centroid
|
||||||
|
// m00 = sum of all pixels (area)
|
||||||
|
// m10 = sum of (x * pixel_value)
|
||||||
|
// m01 = sum of (y * pixel_value)
|
||||||
|
// centroid_x = m10 / m00
|
||||||
|
// centroid_y = m01 / m00
|
||||||
|
|
||||||
|
var m00: Double = 0
|
||||||
|
var m10: Double = 0
|
||||||
|
var m01: Double = 0
|
||||||
|
|
||||||
|
for y in 0..<height {
|
||||||
|
for x in 0..<width {
|
||||||
|
let index = y * width + x
|
||||||
|
let pixelValue = 255 - Int(data[index]) // Invert: we want dark regions
|
||||||
|
|
||||||
|
if pixelValue > 128 { // Only count dark pixels
|
||||||
|
let weight = Double(pixelValue)
|
||||||
|
m00 += weight
|
||||||
|
m10 += Double(x) * weight
|
||||||
|
m01 += Double(y) * weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard m00 > 0 else { return nil }
|
||||||
|
|
||||||
|
let centroidX = m10 / m00
|
||||||
|
let centroidY = m01 / m00
|
||||||
|
|
||||||
|
return (centroidX, centroidY)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
Gaze/Services/ServiceContainer.swift
Normal file
110
Gaze/Services/ServiceContainer.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
//
|
||||||
|
// ServiceContainer.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Dependency injection container for managing service instances.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A simple dependency injection container for managing service instances.
|
||||||
|
/// Supports both production and test configurations.
|
||||||
|
@MainActor
|
||||||
|
final class ServiceContainer {
|
||||||
|
|
||||||
|
/// Shared instance for production use
|
||||||
|
static let shared = ServiceContainer()
|
||||||
|
|
||||||
|
/// The settings manager instance
|
||||||
|
private(set) var settingsManager: any SettingsProviding
|
||||||
|
|
||||||
|
/// The enforce mode service instance
|
||||||
|
private(set) var enforceModeService: EnforceModeService
|
||||||
|
|
||||||
|
/// The timer engine instance (created lazily)
|
||||||
|
private var _timerEngine: TimerEngine?
|
||||||
|
|
||||||
|
/// The fullscreen detection service
|
||||||
|
private(set) var fullscreenService: FullscreenDetectionService?
|
||||||
|
|
||||||
|
/// The idle monitoring service
|
||||||
|
private(set) var idleService: IdleMonitoringService?
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// - Parameters:
|
||||||
|
/// - settingsManager: The settings manager to use (defaults to MockSettingsManager in tests)
|
||||||
|
/// - enforceModeService: The enforce mode service to use
|
||||||
|
init(
|
||||||
|
settingsManager: any SettingsProviding,
|
||||||
|
enforceModeService: EnforceModeService? = nil
|
||||||
|
) {
|
||||||
|
self.isTestEnvironment = true
|
||||||
|
self.settingsManager = settingsManager
|
||||||
|
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets or creates the timer engine
|
||||||
|
var timerEngine: TimerEngine {
|
||||||
|
if let engine = _timerEngine {
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
let engine = TimerEngine(
|
||||||
|
settingsManager: settingsManager,
|
||||||
|
enforceModeService: enforceModeService
|
||||||
|
)
|
||||||
|
_timerEngine = engine
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets up smart mode services
|
||||||
|
func setupSmartModeServices() {
|
||||||
|
let settings = settingsManager.settings
|
||||||
|
|
||||||
|
fullscreenService = FullscreenDetectionService()
|
||||||
|
idleService = IdleMonitoringService(
|
||||||
|
idleThresholdMinutes: settings.smartMode.idleThresholdMinutes
|
||||||
|
)
|
||||||
|
usageTrackingService = UsageTrackingService(
|
||||||
|
resetThresholdMinutes: settings.smartMode.usageResetAfterMinutes
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connect idle service to usage tracking
|
||||||
|
if let idleService = idleService {
|
||||||
|
usageTrackingService?.setupIdleMonitoring(idleService)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect services to timer engine
|
||||||
|
timerEngine.setupSmartMode(
|
||||||
|
fullscreenService: fullscreenService,
|
||||||
|
idleService: idleService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
static func forTesting(settings: AppSettings = .defaults) -> ServiceContainer {
|
||||||
|
// We need to create this at runtime in tests using MockSettingsManager
|
||||||
|
fatalError("Use init(settingsManager:) directly in tests")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ class TimerEngine: ObservableObject {
|
|||||||
@Published var activeReminder: ReminderEvent?
|
@Published var activeReminder: ReminderEvent?
|
||||||
|
|
||||||
private var timerSubscription: AnyCancellable?
|
private var timerSubscription: AnyCancellable?
|
||||||
private let settingsManager: SettingsManager
|
private let settingsProvider: any SettingsProviding
|
||||||
private var sleepStartTime: Date?
|
private var sleepStartTime: Date?
|
||||||
|
|
||||||
// For enforce mode integration
|
// For enforce mode integration
|
||||||
@@ -25,9 +25,9 @@ class TimerEngine: ObservableObject {
|
|||||||
private var idleService: IdleMonitoringService?
|
private var idleService: IdleMonitoringService?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(settingsManager: SettingsManager) {
|
init(settingsManager: any SettingsProviding, enforceModeService: EnforceModeService? = nil) {
|
||||||
self.settingsManager = settingsManager
|
self.settingsProvider = settingsManager
|
||||||
self.enforceModeService = EnforceModeService.shared
|
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.enforceModeService?.setTimerEngine(self)
|
self.enforceModeService?.setTimerEngine(self)
|
||||||
@@ -61,7 +61,7 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleFullscreenChange(isFullscreen: Bool) {
|
private func handleFullscreenChange(isFullscreen: Bool) {
|
||||||
guard settingsManager.settings.smartMode.autoPauseOnFullscreen else { return }
|
guard settingsProvider.settings.smartMode.autoPauseOnFullscreen else { return }
|
||||||
|
|
||||||
if isFullscreen {
|
if isFullscreen {
|
||||||
pauseAllTimers(reason: .fullscreen)
|
pauseAllTimers(reason: .fullscreen)
|
||||||
@@ -73,7 +73,7 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleIdleChange(isIdle: Bool) {
|
private func handleIdleChange(isIdle: Bool) {
|
||||||
guard settingsManager.settings.smartMode.autoPauseOnIdle else { return }
|
guard settingsProvider.settings.smartMode.autoPauseOnIdle else { return }
|
||||||
|
|
||||||
if isIdle {
|
if isIdle {
|
||||||
pauseAllTimers(reason: .idle)
|
pauseAllTimers(reason: .idle)
|
||||||
@@ -114,7 +114,7 @@ class TimerEngine: ObservableObject {
|
|||||||
|
|
||||||
// Add built-in timers
|
// Add built-in timers
|
||||||
for timerType in TimerType.allCases {
|
for timerType in TimerType.allCases {
|
||||||
let config = settingsManager.timerConfiguration(for: timerType)
|
let config = settingsProvider.timerConfiguration(for: timerType)
|
||||||
if config.enabled {
|
if config.enabled {
|
||||||
let identifier = TimerIdentifier.builtIn(timerType)
|
let identifier = TimerIdentifier.builtIn(timerType)
|
||||||
newStates[identifier] = TimerState(
|
newStates[identifier] = TimerState(
|
||||||
@@ -127,7 +127,7 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add user timers
|
// Add user timers
|
||||||
for userTimer in settingsManager.settings.userTimers where userTimer.enabled {
|
for userTimer in settingsProvider.settings.userTimers where userTimer.enabled {
|
||||||
let identifier = TimerIdentifier.user(id: userTimer.id)
|
let identifier = TimerIdentifier.user(id: userTimer.id)
|
||||||
newStates[identifier] = TimerState(
|
newStates[identifier] = TimerState(
|
||||||
identifier: identifier,
|
identifier: identifier,
|
||||||
@@ -159,7 +159,7 @@ class TimerEngine: ObservableObject {
|
|||||||
|
|
||||||
// Update built-in timers
|
// Update built-in timers
|
||||||
for timerType in TimerType.allCases {
|
for timerType in TimerType.allCases {
|
||||||
let config = settingsManager.timerConfiguration(for: timerType)
|
let config = settingsProvider.timerConfiguration(for: timerType)
|
||||||
let identifier = TimerIdentifier.builtIn(timerType)
|
let identifier = TimerIdentifier.builtIn(timerType)
|
||||||
|
|
||||||
if config.enabled {
|
if config.enabled {
|
||||||
@@ -191,7 +191,7 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update user timers
|
// Update user timers
|
||||||
for userTimer in settingsManager.settings.userTimers {
|
for userTimer in settingsProvider.settings.userTimers {
|
||||||
let identifier = TimerIdentifier.user(id: userTimer.id)
|
let identifier = TimerIdentifier.user(id: userTimer.id)
|
||||||
let newIntervalSeconds = userTimer.intervalMinutes * 60
|
let newIntervalSeconds = userTimer.intervalMinutes * 60
|
||||||
|
|
||||||
@@ -269,10 +269,10 @@ class TimerEngine: ObservableObject {
|
|||||||
let intervalSeconds: Int
|
let intervalSeconds: Int
|
||||||
switch identifier {
|
switch identifier {
|
||||||
case .builtIn(let type):
|
case .builtIn(let type):
|
||||||
let config = settingsManager.timerConfiguration(for: type)
|
let config = settingsProvider.timerConfiguration(for: type)
|
||||||
intervalSeconds = config.intervalSeconds
|
intervalSeconds = config.intervalSeconds
|
||||||
case .user(let id):
|
case .user(let id):
|
||||||
guard let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) else { return }
|
guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else { return }
|
||||||
intervalSeconds = userTimer.intervalMinutes * 60
|
intervalSeconds = userTimer.intervalMinutes * 60
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,14 +335,14 @@ class TimerEngine: ObservableObject {
|
|||||||
switch type {
|
switch type {
|
||||||
case .lookAway:
|
case .lookAway:
|
||||||
activeReminder = .lookAwayTriggered(
|
activeReminder = .lookAwayTriggered(
|
||||||
countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds)
|
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds)
|
||||||
case .blink:
|
case .blink:
|
||||||
activeReminder = .blinkTriggered
|
activeReminder = .blinkTriggered
|
||||||
case .posture:
|
case .posture:
|
||||||
activeReminder = .postureTriggered
|
activeReminder = .postureTriggered
|
||||||
}
|
}
|
||||||
case .user(let id):
|
case .user(let id):
|
||||||
if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) {
|
if let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) {
|
||||||
activeReminder = .userTimerTriggered(userTimer)
|
activeReminder = .userTimerTriggered(userTimer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,108 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SettingsWindowPresenter {
|
||||||
|
static let shared = SettingsWindowPresenter()
|
||||||
|
|
||||||
|
private weak 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 focus(tab: Int) {
|
||||||
|
_ = focusExistingWindow(tab: tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
windowController?.close()
|
||||||
|
windowController = nil
|
||||||
|
removeCloseObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func focusExistingWindow(tab: Int?) -> Bool {
|
||||||
|
guard let window = windowController?.window else {
|
||||||
|
windowController = nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let tab {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: Notification.Name("SwitchToSettingsTab"),
|
||||||
|
object: tab
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindow(settingsManager: SettingsManager, initialTab: Int) {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
window.identifier = WindowIdentifiers.settings
|
||||||
|
window.titleVisibility = .hidden
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.toolbarStyle = .unified
|
||||||
|
window.toolbar = NSToolbar()
|
||||||
|
window.center()
|
||||||
|
window.setFrameAutosaveName("SettingsWindow")
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
let contentView = SettingsWindowView(
|
||||||
|
settingsManager: settingsManager,
|
||||||
|
initialTab: initialTab
|
||||||
|
)
|
||||||
|
window.contentView = NSHostingView(rootView: contentView)
|
||||||
|
|
||||||
|
let controller = NSWindowController(window: window)
|
||||||
|
controller.showWindow(nil)
|
||||||
|
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
windowController = controller
|
||||||
|
|
||||||
|
removeCloseObserver()
|
||||||
|
closeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: NSWindow.willCloseNotification,
|
||||||
|
object: window,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.windowController = nil
|
||||||
|
self?.removeCloseObserver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func removeCloseObserver() {
|
||||||
|
if let closeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(closeObserver)
|
||||||
|
self.closeObserver = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
Task { @MainActor in
|
||||||
|
removeCloseObserver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingsWindowView: View {
|
struct SettingsWindowView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@ObservedObject var settingsManager: SettingsManager
|
||||||
@State private var selectedSection: SettingsSection
|
@State private var selectedSection: SettingsSection
|
||||||
|
|||||||
@@ -13,92 +13,98 @@ struct EnforceModeSetupView: View {
|
|||||||
@ObservedObject var cameraService = CameraAccessService.shared
|
@ObservedObject var cameraService = CameraAccessService.shared
|
||||||
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
|
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
|
||||||
@ObservedObject var enforceModeService = EnforceModeService.shared
|
@ObservedObject var enforceModeService = EnforceModeService.shared
|
||||||
|
@ObservedObject var trackingConstants = EyeTrackingConstants.shared
|
||||||
|
|
||||||
@State private var isProcessingToggle = false
|
@State private var isProcessingToggle = false
|
||||||
@State private var isTestModeActive = false
|
@State private var isTestModeActive = false
|
||||||
@State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
|
@State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
|
||||||
@State private var showDebugView = false
|
@State private var showDebugView = false
|
||||||
@State private var isViewActive = false
|
@State private var isViewActive = false
|
||||||
|
@State private var showAdvancedSettings = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 16) {
|
ScrollView {
|
||||||
Image(systemName: "video.fill")
|
VStack(spacing: 16) {
|
||||||
.font(.system(size: 60))
|
Image(systemName: "video.fill")
|
||||||
.foregroundColor(.accentColor)
|
.font(.system(size: 60))
|
||||||
Text("Enforce Mode")
|
.foregroundColor(.accentColor)
|
||||||
.font(.system(size: 28, weight: .bold))
|
Text("Enforce Mode")
|
||||||
}
|
.font(.system(size: 28, weight: .bold))
|
||||||
.padding(.top, 20)
|
}
|
||||||
.padding(.bottom, 30)
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
Text("Use your camera to ensure you take breaks")
|
Text("Use your camera to ensure you take breaks")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Enable Enforce Mode")
|
Text("Enable Enforce Mode")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text("Camera activates 3 seconds before lookaway reminders")
|
Text("Camera activates 3 seconds before lookaway reminders")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle(
|
Toggle(
|
||||||
"",
|
"",
|
||||||
isOn: Binding(
|
isOn: Binding(
|
||||||
get: {
|
get: {
|
||||||
settingsManager.settings.enforcementMode
|
settingsManager.settings.enforcementMode
|
||||||
},
|
},
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
print("🎛️ Toggle changed to: \(newValue)")
|
print("🎛️ Toggle changed to: \(newValue)")
|
||||||
guard !isProcessingToggle else {
|
guard !isProcessingToggle else {
|
||||||
print("⚠️ Already processing toggle")
|
print("⚠️ Already processing toggle")
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
settingsManager.settings.enforcementMode = newValue
|
||||||
|
handleEnforceModeToggle(enabled: newValue)
|
||||||
}
|
}
|
||||||
settingsManager.settings.enforcementMode = newValue
|
)
|
||||||
handleEnforceModeToggle(enabled: newValue)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
.labelsHidden()
|
||||||
.labelsHidden()
|
.disabled(isProcessingToggle)
|
||||||
.disabled(isProcessingToggle)
|
}
|
||||||
}
|
.padding()
|
||||||
.padding()
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
|
||||||
|
|
||||||
cameraStatusView
|
cameraStatusView
|
||||||
|
|
||||||
if enforceModeService.isEnforceModeEnabled {
|
if enforceModeService.isEnforceModeEnabled {
|
||||||
testModeButton
|
testModeButton
|
||||||
}
|
|
||||||
|
|
||||||
if isTestModeActive && enforceModeService.isCameraActive {
|
|
||||||
testModePreviewView
|
|
||||||
} else {
|
|
||||||
if enforceModeService.isCameraActive && !isTestModeActive {
|
|
||||||
eyeTrackingStatusView
|
|
||||||
#if DEBUG
|
|
||||||
if showDebugView {
|
|
||||||
debugEyeTrackingView
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
} else if enforceModeService.isEnforceModeEnabled {
|
|
||||||
cameraPendingView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
privacyInfoView
|
if isTestModeActive && enforceModeService.isCameraActive {
|
||||||
|
testModePreviewView
|
||||||
|
trackingConstantsView
|
||||||
|
} else {
|
||||||
|
if enforceModeService.isCameraActive && !isTestModeActive {
|
||||||
|
trackingConstantsView
|
||||||
|
eyeTrackingStatusView
|
||||||
|
#if DEBUG
|
||||||
|
if showDebugView {
|
||||||
|
debugEyeTrackingView
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
} else if enforceModeService.isEnforceModeEnabled {
|
||||||
|
cameraPendingView
|
||||||
|
}
|
||||||
|
|
||||||
|
privacyInfoView
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
@@ -163,35 +169,35 @@ struct EnforceModeSetupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
/*VStack(alignment: .leading, spacing: 12) {*/
|
||||||
Text("Live Tracking Status")
|
/*Text("Live Tracking Status")*/
|
||||||
.font(.headline)
|
/*.font(.headline)*/
|
||||||
|
|
||||||
HStack(spacing: 20) {
|
/*HStack(spacing: 20) {*/
|
||||||
statusIndicator(
|
/*statusIndicator(*/
|
||||||
title: "Face Detected",
|
/*title: "Face Detected",*/
|
||||||
isActive: eyeTrackingService.faceDetected,
|
/*isActive: eyeTrackingService.faceDetected,*/
|
||||||
icon: "person.fill"
|
/*icon: "person.fill"*/
|
||||||
)
|
/*)*/
|
||||||
|
|
||||||
statusIndicator(
|
/*statusIndicator(*/
|
||||||
title: "Looking Away",
|
/*title: "Looking Away",*/
|
||||||
isActive: !eyeTrackingService.userLookingAtScreen,
|
/*isActive: !eyeTrackingService.userLookingAtScreen,*/
|
||||||
icon: "arrow.turn.up.right"
|
/*icon: "arrow.turn.up.right"*/
|
||||||
)
|
/*)*/
|
||||||
}
|
/*}*/
|
||||||
|
|
||||||
Text(
|
/*Text(*/
|
||||||
lookingAway
|
/*lookingAway*/
|
||||||
? "✓ Break compliance detected" : "⚠️ Please look away from screen"
|
/*? "✓ Break compliance detected" : "⚠️ Please look away from screen"*/
|
||||||
)
|
/*)*/
|
||||||
.font(.caption)
|
/*.font(.caption)*/
|
||||||
.foregroundColor(lookingAway ? .green : .orange)
|
/*.foregroundColor(lookingAway ? .green : .orange)*/
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
/*.frame(maxWidth: .infinity, alignment: .center)*/
|
||||||
.padding(.top, 4)
|
/*.padding(.top, 4)*/
|
||||||
}
|
/*}*/
|
||||||
.padding()
|
/*.padding()*/
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
/*.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,6 +363,269 @@ struct EnforceModeSetupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var trackingConstantsView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Text("Tracking Sensitivity")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
eyeTrackingService.enableDebugLogging.toggle()
|
||||||
|
}) {
|
||||||
|
Image(systemName: eyeTrackingService.enableDebugLogging ? "ant.circle.fill" : "ant.circle")
|
||||||
|
.foregroundColor(eyeTrackingService.enableDebugLogging ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Toggle console debug logging")
|
||||||
|
|
||||||
|
Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") {
|
||||||
|
withAnimation {
|
||||||
|
showAdvancedSettings.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug info always visible when tracking
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Live Values:")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
if let leftRatio = eyeTrackingService.debugLeftPupilRatio,
|
||||||
|
let rightRatio = eyeTrackingService.debugRightPupilRatio {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Left Pupil: \(String(format: "%.3f", leftRatio))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(
|
||||||
|
!trackingConstants.minPupilEnabled && !trackingConstants.maxPupilEnabled ? .secondary :
|
||||||
|
(leftRatio < trackingConstants.minPupilRatio || leftRatio > trackingConstants.maxPupilRatio) ? .orange : .green
|
||||||
|
)
|
||||||
|
Text("Right Pupil: \(String(format: "%.3f", rightRatio))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(
|
||||||
|
!trackingConstants.minPupilEnabled && !trackingConstants.maxPupilEnabled ? .secondary :
|
||||||
|
(rightRatio < trackingConstants.minPupilRatio || rightRatio > trackingConstants.maxPupilRatio) ? .orange : .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("Range: \(String(format: "%.2f", trackingConstants.minPupilRatio)) - \(String(format: "%.2f", trackingConstants.maxPupilRatio))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
let bothEyesOut = (leftRatio < trackingConstants.minPupilRatio || leftRatio > trackingConstants.maxPupilRatio) &&
|
||||||
|
(rightRatio < trackingConstants.minPupilRatio || rightRatio > trackingConstants.maxPupilRatio)
|
||||||
|
Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(bothEyesOut ? .orange : .green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Pupil data unavailable")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let yaw = eyeTrackingService.debugYaw,
|
||||||
|
let pitch = eyeTrackingService.debugPitch {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Yaw: \(String(format: "%.3f", yaw))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(
|
||||||
|
!trackingConstants.yawEnabled ? .secondary :
|
||||||
|
abs(yaw) > trackingConstants.yawThreshold ? .orange : .green
|
||||||
|
)
|
||||||
|
Text("Pitch: \(String(format: "%.3f", pitch))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(
|
||||||
|
!trackingConstants.pitchUpEnabled && !trackingConstants.pitchDownEnabled ? .secondary :
|
||||||
|
(pitch > trackingConstants.pitchUpThreshold || pitch < trackingConstants.pitchDownThreshold) ? .orange : .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("Yaw Max: \(String(format: "%.2f", trackingConstants.yawThreshold))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Pitch: \(String(format: "%.2f", trackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", trackingConstants.pitchUpThreshold))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if showAdvancedSettings {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Yaw Threshold
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Toggle("", isOn: $trackingConstants.yawEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
Text("Yaw Threshold (Head Turn)")
|
||||||
|
.foregroundColor(
|
||||||
|
trackingConstants.yawEnabled ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.2f rad", trackingConstants.yawThreshold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Slider(value: $trackingConstants.yawThreshold, in: 0.1...0.8, step: 0.05)
|
||||||
|
.disabled(!trackingConstants.yawEnabled)
|
||||||
|
Text("Lower = more sensitive to head turning")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Pitch Up Threshold
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Toggle("", isOn: $trackingConstants.pitchUpEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
Text("Pitch Up Threshold (Looking Up)")
|
||||||
|
.foregroundColor(
|
||||||
|
trackingConstants.pitchUpEnabled ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.2f rad", trackingConstants.pitchUpThreshold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value: $trackingConstants.pitchUpThreshold, in: -0.2...0.5, step: 0.05
|
||||||
|
)
|
||||||
|
.disabled(!trackingConstants.pitchUpEnabled)
|
||||||
|
Text("Lower = more sensitive to looking up")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Pitch Down Threshold
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Toggle("", isOn: $trackingConstants.pitchDownEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
Text("Pitch Down Threshold (Looking Down)")
|
||||||
|
.foregroundColor(
|
||||||
|
trackingConstants.pitchDownEnabled ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.2f rad", trackingConstants.pitchDownThreshold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value: $trackingConstants.pitchDownThreshold, in: -0.8...0.0, step: 0.05
|
||||||
|
)
|
||||||
|
.disabled(!trackingConstants.pitchDownEnabled)
|
||||||
|
Text("Higher = more sensitive to looking down")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Min Pupil Ratio
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Toggle("", isOn: $trackingConstants.minPupilEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
Text("Min Pupil Ratio (Looking Right)")
|
||||||
|
.foregroundColor(
|
||||||
|
trackingConstants.minPupilEnabled ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.2f", trackingConstants.minPupilRatio))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Slider(value: $trackingConstants.minPupilRatio, in: 0.2...0.5, step: 0.01)
|
||||||
|
.disabled(!trackingConstants.minPupilEnabled)
|
||||||
|
Text("Higher = more sensitive to looking right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Max Pupil Ratio
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Toggle("", isOn: $trackingConstants.maxPupilEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
Text("Max Pupil Ratio (Looking Left)")
|
||||||
|
.foregroundColor(
|
||||||
|
trackingConstants.maxPupilEnabled ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.2f", trackingConstants.maxPupilRatio))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Slider(value: $trackingConstants.maxPupilRatio, in: 0.5...0.8, step: 0.01)
|
||||||
|
.disabled(!trackingConstants.maxPupilEnabled)
|
||||||
|
Text("Lower = more sensitive to looking left")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Eye Closed Threshold
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Toggle("", isOn: $trackingConstants.eyeClosedEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
Text("Eye Closed Threshold")
|
||||||
|
.foregroundColor(
|
||||||
|
trackingConstants.eyeClosedEnabled ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.3f", trackingConstants.eyeClosedThreshold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(trackingConstants.eyeClosedThreshold) },
|
||||||
|
set: { trackingConstants.eyeClosedThreshold = CGFloat($0) }
|
||||||
|
), in: 0.01...0.1, step: 0.005
|
||||||
|
)
|
||||||
|
.disabled(!trackingConstants.eyeClosedEnabled)
|
||||||
|
Text("Lower = more sensitive to eye closure")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
Button(action: {
|
||||||
|
trackingConstants.resetToDefaults()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
Text("Reset to Defaults")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
private var debugEyeTrackingView: some View {
|
private var debugEyeTrackingView: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("Debug Eye Tracking Data")
|
Text("Debug Eye Tracking Data")
|
||||||
|
|||||||
76
GazeTests/Mocks/MockSettingsManager.swift
Normal file
76
GazeTests/Mocks/MockSettingsManager.swift
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
//
|
||||||
|
// MockSettingsManager.swift
|
||||||
|
// GazeTests
|
||||||
|
//
|
||||||
|
// A mock implementation of SettingsProviding for isolated unit testing.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
@testable import Gaze
|
||||||
|
|
||||||
|
/// A mock implementation of SettingsProviding that doesn't use UserDefaults.
|
||||||
|
/// This allows tests to run in complete isolation without affecting
|
||||||
|
/// the shared singleton or persisting data.
|
||||||
|
@MainActor
|
||||||
|
final class MockSettingsManager: ObservableObject, SettingsProviding {
|
||||||
|
@Published var settings: AppSettings
|
||||||
|
|
||||||
|
var settingsPublisher: Published<AppSettings>.Publisher {
|
||||||
|
$settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
|
||||||
|
.lookAway: \.lookAwayTimer,
|
||||||
|
.blink: \.blinkTimer,
|
||||||
|
.posture: \.postureTimer,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Track method calls for verification in tests
|
||||||
|
var saveCallCount = 0
|
||||||
|
var loadCallCount = 0
|
||||||
|
var resetToDefaultsCallCount = 0
|
||||||
|
|
||||||
|
init(settings: AppSettings = .defaults) {
|
||||||
|
self.settings = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
|
||||||
|
var configs: [TimerType: TimerConfiguration] = [:]
|
||||||
|
for (type, keyPath) in timerConfigKeyPaths {
|
||||||
|
configs[type] = settings[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
saveCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveImmediately() {
|
||||||
|
saveCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
loadCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToDefaults() {
|
||||||
|
resetToDefaultsCallCount += 1
|
||||||
|
settings = .defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user