reorg
This commit is contained in:
@@ -16,6 +16,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
private let windowManager: WindowManaging
|
private let windowManager: WindowManaging
|
||||||
private var updateManager: UpdateManager?
|
private var updateManager: UpdateManager?
|
||||||
private var systemSleepManager: SystemSleepManager?
|
private var systemSleepManager: SystemSleepManager?
|
||||||
|
private var fullscreenService: FullscreenDetectionService?
|
||||||
|
private var idleService: IdleMonitoringService?
|
||||||
|
private var usageTrackingService: UsageTrackingService?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var hasStartedTimers = false
|
private var hasStartedTimers = false
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
)
|
)
|
||||||
systemSleepManager?.startObserving()
|
systemSleepManager?.startObserving()
|
||||||
|
|
||||||
serviceContainer.setupSmartModeServices()
|
setupSmartModeServices()
|
||||||
|
|
||||||
// Initialize update manager after onboarding is complete
|
// Initialize update manager after onboarding is complete
|
||||||
if settingsManager.settings.hasCompletedOnboarding {
|
if settingsManager.settings.hasCompletedOnboarding {
|
||||||
@@ -82,18 +85,45 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.sink { [weak self] smartMode in
|
.sink { [weak self] smartMode in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.serviceContainer.idleService?.updateThreshold(
|
self.idleService?.updateThreshold(
|
||||||
minutes: smartMode.idleThresholdMinutes)
|
minutes: smartMode.idleThresholdMinutes)
|
||||||
self.serviceContainer.usageTrackingService?.updateResetThreshold(
|
self.usageTrackingService?.updateResetThreshold(
|
||||||
minutes: smartMode.usageResetAfterMinutes)
|
minutes: smartMode.usageResetAfterMinutes)
|
||||||
|
|
||||||
// Force state check when settings change to apply immediately
|
// Force state check when settings change to apply immediately
|
||||||
self.serviceContainer.fullscreenService?.forceUpdate()
|
self.fullscreenService?.forceUpdate()
|
||||||
self.serviceContainer.idleService?.forceUpdate()
|
self.idleService?.forceUpdate()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupSmartModeServices() {
|
||||||
|
let settings = settingsManager.settings
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
fullscreenService = await FullscreenDetectionService.create()
|
||||||
|
idleService = IdleMonitoringService(
|
||||||
|
idleThresholdMinutes: settings.smartMode.idleThresholdMinutes
|
||||||
|
)
|
||||||
|
if settings.smartMode.trackUsage {
|
||||||
|
usageTrackingService = UsageTrackingService(
|
||||||
|
resetThresholdMinutes: settings.smartMode.usageResetAfterMinutes
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
usageTrackingService = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let idleService = idleService {
|
||||||
|
usageTrackingService?.setupIdleMonitoring(idleService)
|
||||||
|
}
|
||||||
|
|
||||||
|
timerEngine?.setupSmartMode(
|
||||||
|
fullscreenService: fullscreenService,
|
||||||
|
idleService: idleService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func onboardingCompleted() {
|
func onboardingCompleted() {
|
||||||
startTimers()
|
startTimers()
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
//
|
|
||||||
// EyeDebugStateAdapter.swift
|
|
||||||
// Gaze
|
|
||||||
//
|
|
||||||
// Debug state storage for eye tracking UI.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppKit
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class EyeDebugStateAdapter {
|
|
||||||
var leftPupilRatio: Double?
|
|
||||||
var rightPupilRatio: Double?
|
|
||||||
var leftVerticalRatio: Double?
|
|
||||||
var rightVerticalRatio: Double?
|
|
||||||
var yaw: Double?
|
|
||||||
var pitch: Double?
|
|
||||||
var enableDebugLogging: Bool = false {
|
|
||||||
didSet {
|
|
||||||
PupilDetector.enableDiagnosticLogging = enableDebugLogging
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var leftEyeInput: NSImage?
|
|
||||||
var rightEyeInput: NSImage?
|
|
||||||
var leftEyeProcessed: NSImage?
|
|
||||||
var rightEyeProcessed: NSImage?
|
|
||||||
var leftPupilPosition: PupilPosition?
|
|
||||||
var rightPupilPosition: PupilPosition?
|
|
||||||
var leftEyeSize: CGSize?
|
|
||||||
var rightEyeSize: CGSize?
|
|
||||||
var leftEyeRegion: EyeRegion?
|
|
||||||
var rightEyeRegion: EyeRegion?
|
|
||||||
var imageSize: CGSize?
|
|
||||||
|
|
||||||
var gazeDirection: GazeDirection {
|
|
||||||
guard let leftH = leftPupilRatio,
|
|
||||||
let rightH = rightPupilRatio,
|
|
||||||
let leftV = leftVerticalRatio,
|
|
||||||
let rightV = rightVerticalRatio else {
|
|
||||||
return .center
|
|
||||||
}
|
|
||||||
|
|
||||||
let avgHorizontal = (leftH + rightH) / 2.0
|
|
||||||
let avgVertical = (leftV + rightV) / 2.0
|
|
||||||
|
|
||||||
return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical)
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(from result: EyeTrackingProcessingResult) {
|
|
||||||
leftPupilRatio = result.leftPupilRatio
|
|
||||||
rightPupilRatio = result.rightPupilRatio
|
|
||||||
leftVerticalRatio = result.leftVerticalRatio
|
|
||||||
rightVerticalRatio = result.rightVerticalRatio
|
|
||||||
yaw = result.yaw
|
|
||||||
pitch = result.pitch
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateEyeImages(from detector: PupilDetector.Type) {
|
|
||||||
if let leftInput = detector.debugLeftEyeInput {
|
|
||||||
leftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height))
|
|
||||||
}
|
|
||||||
if let rightInput = detector.debugRightEyeInput {
|
|
||||||
rightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height))
|
|
||||||
}
|
|
||||||
if let leftProcessed = detector.debugLeftEyeProcessed {
|
|
||||||
leftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height))
|
|
||||||
}
|
|
||||||
if let rightProcessed = detector.debugRightEyeProcessed {
|
|
||||||
rightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height))
|
|
||||||
}
|
|
||||||
leftPupilPosition = detector.debugLeftPupilPosition
|
|
||||||
rightPupilPosition = detector.debugRightPupilPosition
|
|
||||||
leftEyeSize = detector.debugLeftEyeSize
|
|
||||||
rightEyeSize = detector.debugRightEyeSize
|
|
||||||
leftEyeRegion = detector.debugLeftEyeRegion
|
|
||||||
rightEyeRegion = detector.debugRightEyeRegion
|
|
||||||
imageSize = detector.debugImageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
func clear() {
|
|
||||||
leftPupilRatio = nil
|
|
||||||
rightPupilRatio = nil
|
|
||||||
leftVerticalRatio = nil
|
|
||||||
rightVerticalRatio = nil
|
|
||||||
yaw = nil
|
|
||||||
pitch = nil
|
|
||||||
leftEyeInput = nil
|
|
||||||
rightEyeInput = nil
|
|
||||||
leftEyeProcessed = nil
|
|
||||||
rightEyeProcessed = nil
|
|
||||||
leftPupilPosition = nil
|
|
||||||
rightPupilPosition = nil
|
|
||||||
leftEyeSize = nil
|
|
||||||
rightEyeSize = nil
|
|
||||||
leftEyeRegion = nil
|
|
||||||
rightEyeRegion = nil
|
|
||||||
imageSize = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
//
|
|
||||||
// EyeTrackingProcessingResult.swift
|
|
||||||
// Gaze
|
|
||||||
//
|
|
||||||
// Shared processing result for eye tracking pipeline.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct EyeTrackingProcessingResult: Sendable {
|
|
||||||
let faceDetected: Bool
|
|
||||||
let isEyesClosed: Bool
|
|
||||||
let userLookingAtScreen: Bool
|
|
||||||
let leftPupilRatio: Double?
|
|
||||||
let rightPupilRatio: Double?
|
|
||||||
let leftVerticalRatio: Double?
|
|
||||||
let rightVerticalRatio: Double?
|
|
||||||
let yaw: Double?
|
|
||||||
let pitch: Double?
|
|
||||||
let faceWidthRatio: Double?
|
|
||||||
}
|
|
||||||
@@ -217,3 +217,97 @@ enum EyeTrackingError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug State Adapter
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class EyeDebugStateAdapter {
|
||||||
|
var leftPupilRatio: Double?
|
||||||
|
var rightPupilRatio: Double?
|
||||||
|
var leftVerticalRatio: Double?
|
||||||
|
var rightVerticalRatio: Double?
|
||||||
|
var yaw: Double?
|
||||||
|
var pitch: Double?
|
||||||
|
var enableDebugLogging: Bool = false {
|
||||||
|
didSet {
|
||||||
|
PupilDetector.enableDiagnosticLogging = enableDebugLogging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var leftEyeInput: NSImage?
|
||||||
|
var rightEyeInput: NSImage?
|
||||||
|
var leftEyeProcessed: NSImage?
|
||||||
|
var rightEyeProcessed: NSImage?
|
||||||
|
var leftPupilPosition: PupilPosition?
|
||||||
|
var rightPupilPosition: PupilPosition?
|
||||||
|
var leftEyeSize: CGSize?
|
||||||
|
var rightEyeSize: CGSize?
|
||||||
|
var leftEyeRegion: EyeRegion?
|
||||||
|
var rightEyeRegion: EyeRegion?
|
||||||
|
var imageSize: CGSize?
|
||||||
|
|
||||||
|
var gazeDirection: GazeDirection {
|
||||||
|
guard let leftH = leftPupilRatio,
|
||||||
|
let rightH = rightPupilRatio,
|
||||||
|
let leftV = leftVerticalRatio,
|
||||||
|
let rightV = rightVerticalRatio else {
|
||||||
|
return .center
|
||||||
|
}
|
||||||
|
|
||||||
|
let avgHorizontal = (leftH + rightH) / 2.0
|
||||||
|
let avgVertical = (leftV + rightV) / 2.0
|
||||||
|
|
||||||
|
return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(from result: EyeTrackingProcessingResult) {
|
||||||
|
leftPupilRatio = result.leftPupilRatio
|
||||||
|
rightPupilRatio = result.rightPupilRatio
|
||||||
|
leftVerticalRatio = result.leftVerticalRatio
|
||||||
|
rightVerticalRatio = result.rightVerticalRatio
|
||||||
|
yaw = result.yaw
|
||||||
|
pitch = result.pitch
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEyeImages(from detector: PupilDetector.Type) {
|
||||||
|
if let leftInput = detector.debugLeftEyeInput {
|
||||||
|
leftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height))
|
||||||
|
}
|
||||||
|
if let rightInput = detector.debugRightEyeInput {
|
||||||
|
rightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height))
|
||||||
|
}
|
||||||
|
if let leftProcessed = detector.debugLeftEyeProcessed {
|
||||||
|
leftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height))
|
||||||
|
}
|
||||||
|
if let rightProcessed = detector.debugRightEyeProcessed {
|
||||||
|
rightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height))
|
||||||
|
}
|
||||||
|
leftPupilPosition = detector.debugLeftPupilPosition
|
||||||
|
rightPupilPosition = detector.debugRightPupilPosition
|
||||||
|
leftEyeSize = detector.debugLeftEyeSize
|
||||||
|
rightEyeSize = detector.debugRightEyeSize
|
||||||
|
leftEyeRegion = detector.debugLeftEyeRegion
|
||||||
|
rightEyeRegion = detector.debugRightEyeRegion
|
||||||
|
imageSize = detector.debugImageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
leftPupilRatio = nil
|
||||||
|
rightPupilRatio = nil
|
||||||
|
leftVerticalRatio = nil
|
||||||
|
rightVerticalRatio = nil
|
||||||
|
yaw = nil
|
||||||
|
pitch = nil
|
||||||
|
leftEyeInput = nil
|
||||||
|
rightEyeInput = nil
|
||||||
|
leftEyeProcessed = nil
|
||||||
|
rightEyeProcessed = nil
|
||||||
|
leftPupilPosition = nil
|
||||||
|
rightPupilPosition = nil
|
||||||
|
leftEyeSize = nil
|
||||||
|
rightEyeSize = nil
|
||||||
|
leftEyeRegion = nil
|
||||||
|
rightEyeRegion = nil
|
||||||
|
imageSize = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,19 @@ import Foundation
|
|||||||
import Vision
|
import Vision
|
||||||
import simd
|
import simd
|
||||||
|
|
||||||
|
struct EyeTrackingProcessingResult: Sendable {
|
||||||
|
let faceDetected: Bool
|
||||||
|
let isEyesClosed: Bool
|
||||||
|
let userLookingAtScreen: Bool
|
||||||
|
let leftPupilRatio: Double?
|
||||||
|
let rightPupilRatio: Double?
|
||||||
|
let leftVerticalRatio: Double?
|
||||||
|
let rightVerticalRatio: Double?
|
||||||
|
let yaw: Double?
|
||||||
|
let pitch: Double?
|
||||||
|
let faceWidthRatio: Double?
|
||||||
|
}
|
||||||
|
|
||||||
final class GazeDetector: @unchecked Sendable {
|
final class GazeDetector: @unchecked Sendable {
|
||||||
struct GazeResult: Sendable {
|
struct GazeResult: Sendable {
|
||||||
let isLookingAway: Bool
|
let isLookingAway: Bool
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
//
|
|
||||||
// FullscreenWindowMatcher.swift
|
|
||||||
// Gaze
|
|
||||||
//
|
|
||||||
// Created by Mike Freno on 1/29/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreGraphics
|
|
||||||
|
|
||||||
struct FullscreenWindowMatcher {
|
|
||||||
func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool {
|
|
||||||
screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool {
|
|
||||||
abs(windowBounds.width - screenFrame.width) < tolerance
|
|
||||||
&& abs(windowBounds.height - screenFrame.height) < tolerance
|
|
||||||
&& abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance
|
|
||||||
&& abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//
|
|
||||||
// IdleMonitoringService.swift
|
|
||||||
// Gaze
|
|
||||||
//
|
|
||||||
// Created by Mike Freno on 1/14/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppKit
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class IdleMonitoringService: ObservableObject {
|
|
||||||
@Published private(set) var isIdle = false
|
|
||||||
@Published private(set) var idleTimeSeconds: TimeInterval = 0
|
|
||||||
|
|
||||||
private var timer: Timer?
|
|
||||||
private var idleThresholdSeconds: TimeInterval
|
|
||||||
|
|
||||||
init(idleThresholdMinutes: Int = 5) {
|
|
||||||
self.idleThresholdSeconds = TimeInterval(idleThresholdMinutes * 60)
|
|
||||||
startMonitoring()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
timer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateThreshold(minutes: Int) {
|
|
||||||
idleThresholdSeconds = TimeInterval(minutes * 60)
|
|
||||||
checkIdleState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startMonitoring() {
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.checkIdleState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkIdleState() {
|
|
||||||
idleTimeSeconds = CGEventSource.secondsSinceLastEventType(
|
|
||||||
.combinedSessionState,
|
|
||||||
eventType: .mouseMoved
|
|
||||||
)
|
|
||||||
|
|
||||||
// Also check keyboard events and use the minimum
|
|
||||||
let keyboardIdleTime = CGEventSource.secondsSinceLastEventType(
|
|
||||||
.combinedSessionState,
|
|
||||||
eventType: .keyDown
|
|
||||||
)
|
|
||||||
|
|
||||||
idleTimeSeconds = min(idleTimeSeconds, keyboardIdleTime)
|
|
||||||
|
|
||||||
let wasIdle = isIdle
|
|
||||||
isIdle = idleTimeSeconds >= idleThresholdSeconds
|
|
||||||
|
|
||||||
if wasIdle != isIdle {
|
|
||||||
print("🔄 Idle state changed: \(isIdle ? "IDLE" : "ACTIVE") (idle: \(Int(idleTimeSeconds))s, threshold: \(Int(idleThresholdSeconds))s)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func forceUpdate() {
|
|
||||||
checkIdleState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
//
|
|
||||||
// MockWindowManager.swift
|
|
||||||
// Gaze
|
|
||||||
//
|
|
||||||
// Mock implementation of WindowManaging for testing purposes.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Mock window manager that tracks window operations without creating actual windows.
|
|
||||||
/// Useful for unit testing UI flows and state management.
|
|
||||||
@MainActor
|
|
||||||
final class MockWindowManager: WindowManaging {
|
|
||||||
|
|
||||||
// MARK: - State Tracking
|
|
||||||
|
|
||||||
private(set) var isOverlayReminderVisible = false
|
|
||||||
private(set) var isSubtleReminderVisible = false
|
|
||||||
|
|
||||||
// MARK: - Operation History
|
|
||||||
|
|
||||||
struct WindowOperation {
|
|
||||||
let timestamp: Date
|
|
||||||
let operation: Operation
|
|
||||||
|
|
||||||
enum Operation {
|
|
||||||
case showOverlayReminder
|
|
||||||
case showSubtleReminder
|
|
||||||
case dismissOverlayReminder
|
|
||||||
case dismissSubtleReminder
|
|
||||||
case dismissAllReminders
|
|
||||||
case showSettings(initialTab: Int)
|
|
||||||
case showOnboarding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private(set) var operations: [WindowOperation] = []
|
|
||||||
|
|
||||||
// MARK: - Callbacks for Testing
|
|
||||||
|
|
||||||
var onShowOverlayReminder: (() -> Void)?
|
|
||||||
var onShowSubtleReminder: (() -> Void)?
|
|
||||||
var onDismissOverlayReminder: (() -> Void)?
|
|
||||||
var onDismissSubtleReminder: (() -> Void)?
|
|
||||||
var onShowSettings: ((Int) -> Void)?
|
|
||||||
var onShowOnboarding: (() -> Void)?
|
|
||||||
|
|
||||||
// MARK: - WindowManaging Implementation
|
|
||||||
|
|
||||||
func showReminderWindow<Content: View>(_ content: Content, windowType: ReminderWindowType) {
|
|
||||||
let operation: WindowOperation.Operation
|
|
||||||
|
|
||||||
switch windowType {
|
|
||||||
case .overlay:
|
|
||||||
isOverlayReminderVisible = true
|
|
||||||
operation = .showOverlayReminder
|
|
||||||
onShowOverlayReminder?()
|
|
||||||
case .subtle:
|
|
||||||
isSubtleReminderVisible = true
|
|
||||||
operation = .showSubtleReminder
|
|
||||||
onShowSubtleReminder?()
|
|
||||||
}
|
|
||||||
|
|
||||||
operations.append(WindowOperation(timestamp: Date(), operation: operation))
|
|
||||||
}
|
|
||||||
|
|
||||||
func dismissOverlayReminder() {
|
|
||||||
isOverlayReminderVisible = false
|
|
||||||
operations.append(WindowOperation(timestamp: Date(), operation: .dismissOverlayReminder))
|
|
||||||
onDismissOverlayReminder?()
|
|
||||||
}
|
|
||||||
|
|
||||||
func dismissSubtleReminder() {
|
|
||||||
isSubtleReminderVisible = false
|
|
||||||
operations.append(WindowOperation(timestamp: Date(), operation: .dismissSubtleReminder))
|
|
||||||
onDismissSubtleReminder?()
|
|
||||||
}
|
|
||||||
|
|
||||||
func dismissAllReminders() {
|
|
||||||
isOverlayReminderVisible = false
|
|
||||||
isSubtleReminderVisible = false
|
|
||||||
operations.append(WindowOperation(timestamp: Date(), operation: .dismissAllReminders))
|
|
||||||
onDismissOverlayReminder?()
|
|
||||||
onDismissSubtleReminder?()
|
|
||||||
}
|
|
||||||
|
|
||||||
func showSettings(settingsManager: any SettingsProviding, initialTab: Int) {
|
|
||||||
operations.append(WindowOperation(timestamp: Date(), operation: .showSettings(initialTab: initialTab)))
|
|
||||||
onShowSettings?(initialTab)
|
|
||||||
}
|
|
||||||
|
|
||||||
func showOnboarding(settingsManager: any SettingsProviding) {
|
|
||||||
operations.append(WindowOperation(timestamp: Date(), operation: .showOnboarding))
|
|
||||||
onShowOnboarding?()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
/// Resets all state for a fresh test
|
|
||||||
func reset() {
|
|
||||||
isOverlayReminderVisible = false
|
|
||||||
isSubtleReminderVisible = false
|
|
||||||
operations.removeAll()
|
|
||||||
onShowOverlayReminder = nil
|
|
||||||
onShowSubtleReminder = nil
|
|
||||||
onDismissOverlayReminder = nil
|
|
||||||
onDismissSubtleReminder = nil
|
|
||||||
onShowSettings = nil
|
|
||||||
onShowOnboarding = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the number of times a specific operation was performed
|
|
||||||
func operationCount(_ operationType: WindowOperation.Operation) -> Int {
|
|
||||||
operations.filter { operation in
|
|
||||||
switch (operation.operation, operationType) {
|
|
||||||
case (.showOverlayReminder, .showOverlayReminder),
|
|
||||||
(.showSubtleReminder, .showSubtleReminder),
|
|
||||||
(.dismissOverlayReminder, .dismissOverlayReminder),
|
|
||||||
(.dismissSubtleReminder, .dismissSubtleReminder),
|
|
||||||
(.dismissAllReminders, .dismissAllReminders),
|
|
||||||
(.showOnboarding, .showOnboarding):
|
|
||||||
return true
|
|
||||||
case (.showSettings(let tab1), .showSettings(let tab2)):
|
|
||||||
return tab1 == tab2
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}.count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the operation was performed at least once
|
|
||||||
func didPerformOperation(_ operationType: WindowOperation.Operation) -> Bool {
|
|
||||||
operationCount(operationType) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the last operation performed, if any
|
|
||||||
var lastOperation: WindowOperation? {
|
|
||||||
operations.last
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Equatable Conformance for Testing
|
|
||||||
|
|
||||||
extension MockWindowManager.WindowOperation.Operation: Equatable {
|
|
||||||
static func == (lhs: MockWindowManager.WindowOperation.Operation, rhs: MockWindowManager.WindowOperation.Operation) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case (.showOverlayReminder, .showOverlayReminder),
|
|
||||||
(.showSubtleReminder, .showSubtleReminder),
|
|
||||||
(.dismissOverlayReminder, .dismissOverlayReminder),
|
|
||||||
(.dismissSubtleReminder, .dismissSubtleReminder),
|
|
||||||
(.dismissAllReminders, .dismissAllReminders),
|
|
||||||
(.showOnboarding, .showOnboarding):
|
|
||||||
return true
|
|
||||||
case (.showSettings(let tab1), .showSettings(let tab2)):
|
|
||||||
return tab1 == tab2
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,15 +23,6 @@ final class ServiceContainer {
|
|||||||
/// The timer engine instance (created lazily)
|
/// The timer engine instance (created lazily)
|
||||||
private var _timerEngine: TimerEngine?
|
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?
|
|
||||||
|
|
||||||
/// Creates a production container with real services
|
/// Creates a production container with real services
|
||||||
private init() {
|
private init() {
|
||||||
self.settingsManager = SettingsManager.shared
|
self.settingsManager = SettingsManager.shared
|
||||||
@@ -64,30 +55,4 @@ final class ServiceContainer {
|
|||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up smart mode services
|
|
||||||
func setupSmartModeServices() {
|
|
||||||
let settings = settingsManager.settings
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
fullscreenService = await FullscreenDetectionService.create()
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,3 +191,16 @@ final class FullscreenDetectionService: ObservableObject {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FullscreenWindowMatcher {
|
||||||
|
func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool {
|
||||||
|
screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool {
|
||||||
|
abs(windowBounds.width - screenFrame.width) < tolerance
|
||||||
|
&& abs(windowBounds.height - screenFrame.height) < tolerance
|
||||||
|
&& abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance
|
||||||
|
&& abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,72 @@
|
|||||||
//
|
//
|
||||||
// UsageTrackingService.swift
|
// IdleMonitoringService.swift
|
||||||
// Gaze
|
// Gaze
|
||||||
//
|
//
|
||||||
// Created by Mike Freno on 1/14/26.
|
// Created by Mike Freno on 1/14/26.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class IdleMonitoringService: ObservableObject {
|
||||||
|
@Published private(set) var isIdle = false
|
||||||
|
@Published private(set) var idleTimeSeconds: TimeInterval = 0
|
||||||
|
|
||||||
|
private var timer: Timer?
|
||||||
|
private var idleThresholdSeconds: TimeInterval
|
||||||
|
|
||||||
|
init(idleThresholdMinutes: Int = 5) {
|
||||||
|
self.idleThresholdSeconds = TimeInterval(idleThresholdMinutes * 60)
|
||||||
|
startMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateThreshold(minutes: Int) {
|
||||||
|
idleThresholdSeconds = TimeInterval(minutes * 60)
|
||||||
|
checkIdleState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startMonitoring() {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.checkIdleState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkIdleState() {
|
||||||
|
idleTimeSeconds = CGEventSource.secondsSinceLastEventType(
|
||||||
|
.combinedSessionState,
|
||||||
|
eventType: .mouseMoved
|
||||||
|
)
|
||||||
|
|
||||||
|
// Also check keyboard events and use the minimum
|
||||||
|
let keyboardIdleTime = CGEventSource.secondsSinceLastEventType(
|
||||||
|
.combinedSessionState,
|
||||||
|
eventType: .keyDown
|
||||||
|
)
|
||||||
|
|
||||||
|
idleTimeSeconds = min(idleTimeSeconds, keyboardIdleTime)
|
||||||
|
|
||||||
|
let wasIdle = isIdle
|
||||||
|
isIdle = idleTimeSeconds >= idleThresholdSeconds
|
||||||
|
|
||||||
|
if wasIdle != isIdle {
|
||||||
|
print("🔄 Idle state changed: \(isIdle ? "IDLE" : "ACTIVE") (idle: \(Int(idleTimeSeconds))s, threshold: \(Int(idleThresholdSeconds))s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceUpdate() {
|
||||||
|
checkIdleState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct UsageStatistics: Codable {
|
struct UsageStatistics: Codable {
|
||||||
var totalActiveSeconds: TimeInterval
|
var totalActiveSeconds: TimeInterval
|
||||||
var totalIdleSeconds: TimeInterval
|
var totalIdleSeconds: TimeInterval
|
||||||
64
GazeTests/Helpers/MockWindowManager.swift
Normal file
64
GazeTests/Helpers/MockWindowManager.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// MockWindowManager.swift
|
||||||
|
// GazeTests
|
||||||
|
//
|
||||||
|
// Mock window manager for tests.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
@testable import Gaze
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MockWindowManager: WindowManaging {
|
||||||
|
private(set) var didShowOnboarding = false
|
||||||
|
private(set) var didShowSettings = false
|
||||||
|
private(set) var didShowReminder = false
|
||||||
|
private(set) var didDismissReminder = false
|
||||||
|
|
||||||
|
var isOverlayReminderVisible: Bool = false
|
||||||
|
var isSubtleReminderVisible: Bool = false
|
||||||
|
|
||||||
|
func showOnboarding(settingsManager: any SettingsProviding) {
|
||||||
|
didShowOnboarding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func showSettings(settingsManager: any SettingsProviding, initialTab: Int) {
|
||||||
|
didShowSettings = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func showReminderWindow<Content: View>(_ content: Content, windowType: ReminderWindowType) {
|
||||||
|
didShowReminder = true
|
||||||
|
switch windowType {
|
||||||
|
case .overlay:
|
||||||
|
isOverlayReminderVisible = true
|
||||||
|
case .subtle:
|
||||||
|
isSubtleReminderVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissOverlayReminder() {
|
||||||
|
didDismissReminder = true
|
||||||
|
isOverlayReminderVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissSubtleReminder() {
|
||||||
|
didDismissReminder = true
|
||||||
|
isSubtleReminderVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissAllReminders() {
|
||||||
|
didDismissReminder = true
|
||||||
|
isOverlayReminderVisible = false
|
||||||
|
isSubtleReminderVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
didShowOnboarding = false
|
||||||
|
didShowSettings = false
|
||||||
|
didShowReminder = false
|
||||||
|
didDismissReminder = false
|
||||||
|
isOverlayReminderVisible = false
|
||||||
|
isSubtleReminderVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user