This commit is contained in:
Michael Freno
2026-01-29 13:15:52 -05:00
parent dbe6923f99
commit 6e3730849b
7 changed files with 352 additions and 445 deletions

View File

@@ -1,97 +0,0 @@
//
// EnforceCameraController.swift
// Gaze
//
// Manages camera lifecycle for enforce mode sessions.
//
import Combine
import Foundation
protocol EnforceCameraControllerDelegate: AnyObject {
func cameraControllerDidTimeout(_ controller: EnforceCameraController)
func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool)
}
@MainActor
final class EnforceCameraController: ObservableObject {
@Published private(set) var isCameraActive = false
@Published private(set) var lastFaceDetectionTime: Date = .distantPast
weak var delegate: EnforceCameraControllerDelegate?
private let eyeTrackingService: EyeTrackingService
private var cancellables = Set<AnyCancellable>()
private var faceDetectionTimer: Timer?
var faceDetectionTimeout: TimeInterval = 5.0
init(eyeTrackingService: EyeTrackingService) {
self.eyeTrackingService = eyeTrackingService
setupObservers()
}
func startCamera() async throws {
guard !isCameraActive else { return }
try await eyeTrackingService.startEyeTracking()
isCameraActive = true
lastFaceDetectionTime = Date()
startFaceDetectionTimer()
}
func stopCamera() {
guard isCameraActive else { return }
eyeTrackingService.stopEyeTracking()
isCameraActive = false
stopFaceDetectionTimer()
}
func resetFaceDetectionTimer() {
lastFaceDetectionTime = Date()
}
private func setupObservers() {
eyeTrackingService.$userLookingAtScreen
.sink { [weak self] lookingAtScreen in
guard let self else { return }
self.delegate?.cameraController(self, didUpdateLookingAtScreen: lookingAtScreen)
}
.store(in: &cancellables)
eyeTrackingService.$faceDetected
.sink { [weak self] faceDetected in
guard let self else { return }
if faceDetected {
self.lastFaceDetectionTime = Date()
}
}
.store(in: &cancellables)
}
private func startFaceDetectionTimer() {
stopFaceDetectionTimer()
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor [weak self] in
self?.checkFaceDetectionTimeout()
}
}
}
private func stopFaceDetectionTimer() {
faceDetectionTimer?.invalidate()
faceDetectionTimer = nil
}
private func checkFaceDetectionTimeout() {
guard isCameraActive else {
stopFaceDetectionTimer()
return
}
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
if timeSinceLastDetection > faceDetectionTimeout {
delegate?.cameraControllerDidTimeout(self)
}
}
}

View File

@@ -1,53 +0,0 @@
//
// EnforcePolicyEvaluator.swift
// Gaze
//
// Policy evaluation for enforce mode behavior.
//
import Foundation
enum ComplianceResult {
case compliant
case notCompliant
case faceNotDetected
}
final class EnforcePolicyEvaluator {
private let settingsProvider: any SettingsProviding
init(settingsProvider: any SettingsProviding) {
self.settingsProvider = settingsProvider
}
var isEnforcementEnabled: Bool {
settingsProvider.isTimerEnabled(for: .lookAway)
}
func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool {
guard isEnforcementEnabled else { return false }
switch timerIdentifier {
case .builtIn(let type):
return type == .lookAway
case .user:
return false
}
}
func shouldPreActivateCamera(
timerIdentifier: TimerIdentifier,
secondsRemaining: Int
) -> Bool {
guard secondsRemaining <= 3 else { return false }
return shouldEnforce(timerIdentifier: timerIdentifier)
}
func evaluateCompliance(
isLookingAtScreen: Bool,
faceDetected: Bool
) -> ComplianceResult {
guard faceDetected else { return .faceNotDetected }
return isLookingAtScreen ? .notCompliant : .compliant
}
}

View File

@@ -8,6 +8,139 @@
import Combine import Combine
import Foundation import Foundation
enum ComplianceResult {
case compliant
case notCompliant
case faceNotDetected
}
protocol EnforceCameraControllerDelegate: AnyObject {
func cameraControllerDidTimeout(_ controller: EnforceCameraController)
func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool)
}
final class EnforcePolicyEvaluator {
private let settingsProvider: any SettingsProviding
init(settingsProvider: any SettingsProviding) {
self.settingsProvider = settingsProvider
}
var isEnforcementEnabled: Bool {
settingsProvider.isTimerEnabled(for: .lookAway)
}
func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool {
guard isEnforcementEnabled else { return false }
switch timerIdentifier {
case .builtIn(let type):
return type == .lookAway
case .user:
return false
}
}
func shouldPreActivateCamera(
timerIdentifier: TimerIdentifier,
secondsRemaining: Int
) -> Bool {
guard secondsRemaining <= 3 else { return false }
return shouldEnforce(timerIdentifier: timerIdentifier)
}
func evaluateCompliance(
isLookingAtScreen: Bool,
faceDetected: Bool
) -> ComplianceResult {
guard faceDetected else { return .faceNotDetected }
return isLookingAtScreen ? .notCompliant : .compliant
}
}
@MainActor
class EnforceCameraController: ObservableObject {
@Published private(set) var isCameraActive = false
@Published private(set) var lastFaceDetectionTime: Date = .distantPast
weak var delegate: EnforceCameraControllerDelegate?
private let eyeTrackingService: EyeTrackingService
private var cancellables = Set<AnyCancellable>()
private var faceDetectionTimer: Timer?
var faceDetectionTimeout: TimeInterval = 5.0
init(eyeTrackingService: EyeTrackingService) {
self.eyeTrackingService = eyeTrackingService
setupObservers()
}
func startCamera() async throws {
guard !isCameraActive else { return }
try await eyeTrackingService.startEyeTracking()
isCameraActive = true
lastFaceDetectionTime = Date()
startFaceDetectionTimer()
}
func stopCamera() {
guard isCameraActive else { return }
eyeTrackingService.stopEyeTracking()
isCameraActive = false
stopFaceDetectionTimer()
}
func resetFaceDetectionTimer() {
lastFaceDetectionTime = Date()
}
private func setupObservers() {
eyeTrackingService.$userLookingAtScreen
.sink { [weak self] lookingAtScreen in
guard let self else { return }
self.delegate?.cameraController(self, didUpdateLookingAtScreen: lookingAtScreen)
}
.store(in: &cancellables)
eyeTrackingService.$faceDetected
.sink { [weak self] faceDetected in
guard let self else { return }
if faceDetected {
self.lastFaceDetectionTime = Date()
}
}
.store(in: &cancellables)
}
private func startFaceDetectionTimer() {
stopFaceDetectionTimer()
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor [weak self] in
self?.checkFaceDetectionTimeout()
}
}
}
private func stopFaceDetectionTimer() {
faceDetectionTimer?.invalidate()
faceDetectionTimer = nil
}
private func checkFaceDetectionTimeout() {
guard isCameraActive else {
stopFaceDetectionTimer()
return
}
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
if timeSinceLastDetection > faceDetectionTimeout {
delegate?.cameraControllerDidTimeout(self)
}
}
}
@MainActor @MainActor
class EnforceModeService: ObservableObject { class EnforceModeService: ObservableObject {
static let shared = EnforceModeService() static let shared = EnforceModeService()

View File

@@ -1,64 +0,0 @@
//
// ReminderManager.swift
// Gaze
//
// Manages reminder triggering and dismissal logic for timers.
//
import Combine
import Foundation
@MainActor
class ReminderManager: ObservableObject {
@Published var activeReminder: ReminderEvent?
private let settingsProvider: any SettingsProviding
private var enforceModeService: EnforceModeService?
private var timerEngine: TimerEngine?
init(
settingsProvider: any SettingsProviding,
enforceModeService: EnforceModeService? = nil
) {
self.settingsProvider = settingsProvider
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
}
func setTimerEngine(_ engine: TimerEngine) {
self.timerEngine = engine
}
func triggerReminder(for identifier: TimerIdentifier) {
// Pause only the timer that triggered
timerEngine?.pauseTimer(identifier: identifier)
// Unified approach to handle all timer types - no more special handling
switch identifier {
case .builtIn(let type):
switch type {
case .lookAway:
activeReminder = .lookAwayTriggered(
countdownSeconds: settingsProvider.timerIntervalMinutes(for: .lookAway) * 60)
case .blink:
activeReminder = .blinkTriggered
case .posture:
activeReminder = .postureTriggered
}
case .user(let id):
if let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) {
activeReminder = .userTimerTriggered(userTimer)
}
}
}
func dismissReminder() {
guard let reminder = activeReminder else { return }
activeReminder = nil
let identifier = reminder.identifier
timerEngine?.skipNext(identifier: identifier)
timerEngine?.resumeTimer(identifier: identifier)
enforceModeService?.handleReminderDismissed()
}
}

View File

@@ -1,229 +0,0 @@
//
// TimerManager.swift
// Gaze
//
// Manages timer creation, state updates, and lifecycle operations.
//
import Combine
import Foundation
@MainActor
class TimerManager: ObservableObject {
@Published var timerStates: [TimerIdentifier: TimerState] = [:]
private let settingsProvider: any SettingsProviding
private var timerSubscription: AnyCancellable?
private let timeProvider: TimeProviding
init(
settingsManager: any SettingsProviding,
timeProvider: TimeProviding
) {
self.settingsProvider = settingsManager
self.timeProvider = timeProvider
}
func start() {
// If timers are already running, just update configurations without resetting
if timerSubscription != nil {
updateConfigurations()
return
}
// Initial start - create all timer states
stop()
var newStates: [TimerIdentifier: TimerState] = [:]
// Add built-in timers (using unified approach)
for timerType in TimerType.allCases {
let intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60
if settingsProvider.isTimerEnabled(for: timerType) {
let identifier = TimerIdentifier.builtIn(timerType)
newStates[identifier] = TimerStateBuilder.make(
identifier: identifier,
intervalSeconds: intervalSeconds
)
}
}
// Add user timers (using unified approach)
for userTimer in settingsProvider.settings.userTimers where userTimer.enabled {
let identifier = TimerIdentifier.user(id: userTimer.id)
newStates[identifier] = TimerStateBuilder.make(
identifier: identifier,
intervalSeconds: userTimer.intervalMinutes * 60
)
}
// Assign the entire dictionary at once to trigger @Published
timerStates = newStates
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
Task { @MainActor in
self?.handleTick()
}
}
}
func stop() {
timerSubscription?.cancel()
timerSubscription = nil
timerStates.removeAll()
}
func updateConfigurations() {
// Update configurations from settings
var newStates: [TimerIdentifier: TimerState] = [:]
// Update built-in timers (using unified approach)
for timerType in TimerType.allCases {
let intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60
let identifier = TimerIdentifier.builtIn(timerType)
if settingsProvider.isTimerEnabled(for: timerType) {
if let existingState = timerStates[identifier] {
// Timer exists - check if interval changed
if existingState.originalIntervalSeconds != intervalSeconds {
// Interval changed - reset with new interval
var updatedState = existingState
updatedState.reset(intervalSeconds: intervalSeconds, keepPaused: true)
newStates[identifier] = updatedState
} else {
// Interval unchanged - keep existing state
newStates[identifier] = existingState
}
} else {
// Timer was just enabled - create new state
newStates[identifier] = TimerStateBuilder.make(
identifier: identifier,
intervalSeconds: intervalSeconds
)
}
}
// If timer is disabled, it will be removed
}
// Update user timers (using unified approach)
for userTimer in settingsProvider.settings.userTimers {
let identifier = TimerIdentifier.user(id: userTimer.id)
let newIntervalSeconds = userTimer.intervalMinutes * 60
if userTimer.enabled {
if let existingState = timerStates[identifier] {
// Check if interval changed
if existingState.originalIntervalSeconds != newIntervalSeconds {
// Interval changed - reset with new interval
var updatedState = existingState
updatedState.reset(intervalSeconds: newIntervalSeconds, keepPaused: true)
newStates[identifier] = updatedState
} else {
// Interval unchanged - keep existing state
newStates[identifier] = existingState
}
} else {
// New timer - create state
newStates[identifier] = TimerStateBuilder.make(
identifier: identifier,
intervalSeconds: newIntervalSeconds
)
}
}
// If timer is disabled, it will be removed
}
// Assign the entire dictionary at once to trigger @Published
timerStates = newStates
}
private func handleTick() {
for (identifier, state) in timerStates {
guard !state.isPaused else { continue }
guard state.isActive else { continue }
if state.targetDate(using: timeProvider) < timeProvider.now() - 3.0 {
// Timer has expired but with some grace period
continue
}
timerStates[identifier]?.remainingSeconds -= 1
if let updatedState = timerStates[identifier] {
// Update remaining seconds for the timer
if updatedState.remainingSeconds <= 0 {
// This would normally trigger a reminder in a full implementation,
// but we're decomposing it to separate components
}
}
}
}
func pause() {
for (id, var state) in timerStates {
state.pauseReasons.insert(.manual)
state.isPaused = true
timerStates[id] = state
}
}
func resume() {
for (id, var state) in timerStates {
state.pauseReasons.remove(.manual)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[id] = state
}
}
func pauseTimer(identifier: TimerIdentifier) {
guard var state = timerStates[identifier] else { return }
state.pauseReasons.insert(.manual)
state.isPaused = true
timerStates[identifier] = state
}
func resumeTimer(identifier: TimerIdentifier) {
guard var state = timerStates[identifier] else { return }
state.pauseReasons.remove(.manual)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[identifier] = state
}
func skipNext(identifier: TimerIdentifier) {
guard let state = timerStates[identifier] else { return }
// Unified approach to get interval - no more separate handling for user timers
let intervalSeconds = getTimerInterval(for: identifier)
var updatedState = state
updatedState.reset(intervalSeconds: intervalSeconds, keepPaused: true)
timerStates[identifier] = updatedState
}
/// Unified way to get interval for any timer type
private func getTimerInterval(for identifier: TimerIdentifier) -> Int {
switch identifier {
case .builtIn(let type):
return settingsProvider.timerIntervalMinutes(for: type) * 60
case .user(let id):
guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else {
return 0
}
return userTimer.intervalMinutes * 60
}
}
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
timerStates[identifier]?.remainingDuration ?? 0
}
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {
return getTimeRemaining(for: identifier).formatAsTimerDurationFull()
}
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
return timerStates[identifier]?.isPaused ?? true
}
}

View File

@@ -0,0 +1,217 @@
//
// EnforcePolicyEvaluatorTests.swift
// GazeTests
//
// Unit tests for EnforcePolicyEvaluator (now nested in EnforceModeService).
//
import XCTest
@testable import Gaze
@MainActor
final class EnforcePolicyEvaluatorTests: XCTestCase {
var evaluator: EnforcePolicyEvaluator!
var mockSettings: EnhancedMockSettingsManager!
override func setUp() async throws {
mockSettings = EnhancedMockSettingsManager(settings: .defaults)
evaluator = EnforcePolicyEvaluator(settingsProvider: mockSettings)
}
override func tearDown() async throws {
evaluator = nil
mockSettings = nil
}
// MARK: - Initialization Tests
func testInitialization() {
XCTAssertNotNil(evaluator)
}
func testInitializationWithSettingsProvider() {
let newSettings = EnhancedMockSettingsManager(settings: AppSettings.defaults)
let newEvaluator = EnforcePolicyEvaluator(settingsProvider: newSettings)
XCTAssertNotNil(newEvaluator)
}
// MARK: - Enforcement Enabled Tests
func testIsEnforcementEnabledWhenLookAwayDisabled() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: false)
let isEnabled = evaluator.isEnforcementEnabled
XCTAssertFalse(isEnabled)
}
func testIsEnforcementEnabledWhenLookAwayEnabled() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
let isEnabled = evaluator.isEnforcementEnabled
XCTAssertTrue(isEnabled)
}
// MARK: - Should Enforce Tests
func testShouldEnforceWhenLookAwayEnabled() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.lookAway))
XCTAssertTrue(shouldEnforce)
}
func testShouldEnforceWhenLookAwayDisabled() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: false)
let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.lookAway))
XCTAssertFalse(shouldEnforce)
}
func testShouldEnforceUserTimerNever() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .user)
XCTAssertFalse(shouldEnforce)
}
func testShouldEnforceBuiltInPostureTimerNever() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.posture))
XCTAssertFalse(shouldEnforce)
}
func testShouldEnforceBuiltInBlinkTimerNever() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.blink))
XCTAssertFalse(shouldEnforce)
}
// MARK: - Pre-activate Camera Tests
func testShouldPreActivateCameraWhenTimerDisabled() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: false)
let shouldPreActivate = evaluator.shouldPreActivateCamera(
timerIdentifier: .builtIn(.lookAway),
secondsRemaining: 3
)
XCTAssertFalse(shouldPreActivate)
}
func testShouldPreActivateCameraWhenSecondsRemainingTooHigh() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
let shouldPreActivate = evaluator.shouldPreActivateCamera(
timerIdentifier: .builtIn(.lookAway),
secondsRemaining: 5
)
XCTAssertFalse(shouldPreActivate)
}
func testShouldPreActivateCameraWhenAllConditionsMet() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
let shouldPreActivate = evaluator.shouldPreActivateCamera(
timerIdentifier: .builtIn(.lookAway),
secondsRemaining: 2
)
XCTAssertTrue(shouldPreActivate)
}
func testShouldPreActivateCameraForUserTimerNever() {
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
let shouldPreActivate = evaluator.shouldPreActivateCamera(
timerIdentifier: .user,
secondsRemaining: 1
)
XCTAssertFalse(shouldPreActivate)
}
// MARK: - Compliance Evaluation Tests
func testEvaluateComplianceWhenLookingAtScreenAndFaceDetected() {
let result = evaluator.evaluateCompliance(
isLookingAtScreen: true,
faceDetected: true
)
XCTAssertEqual(result, .notCompliant)
}
func testEvaluateComplianceWhenNotLookingAtScreenAndFaceDetected() {
let result = evaluator.evaluateCompliance(
isLookingAtScreen: false,
faceDetected: true
)
XCTAssertEqual(result, .compliant)
}
func testEvaluateComplianceWhenFaceNotDetected() {
let result = evaluator.evaluateCompliance(
isLookingAtScreen: true,
faceDetected: false
)
XCTAssertEqual(result, .faceNotDetected)
}
func testEvaluateComplianceWhenFaceNotDetectedAndNotLookingAtScreen() {
let result = evaluator.evaluateCompliance(
isLookingAtScreen: false,
faceDetected: false
)
XCTAssertEqual(result, .faceNotDetected)
}
func testEvaluateComplianceWhenFaceNotDetectedAndNotLookingAtScreen() {
// Test edge case - should still return face not detected
let result = evaluator.evaluateCompliance(
isLookingAtScreen: false,
faceDetected: false
)
XCTAssertEqual(result, .faceNotDetected)
}
// MARK: - Integration Tests
func testFullEnforcementFlow() {
// Setup: Look away timer enabled
mockSettings.updateTimerEnabled(for: .lookAway, enabled: true)
// Test 1: Check enforcement
let shouldEnforce = evaluator.shouldEnforce(timerIdentifier: .builtIn(.lookAway))
XCTAssertTrue(shouldEnforce)
// Test 2: Check pre-activation at 3 seconds
let shouldPreActivate = evaluator.shouldPreActivateCamera(
timerIdentifier: .builtIn(.lookAway),
secondsRemaining: 3
)
XCTAssertTrue(shouldPreActivate)
// Test 3: Check compliance when looking at screen
let compliance = evaluator.evaluateCompliance(
isLookingAtScreen: true,
faceDetected: true
)
XCTAssertEqual(compliance, .notCompliant)
}
}

View File

@@ -265,8 +265,8 @@ final class TimerEngineTests: XCTestCase {
systemSleepManager.handleSystemWillSleep() systemSleepManager.handleSystemWillSleep()
// States should still exist // States should be cleared
XCTAssertEqual(timerEngine.timerStates.count, statesBefore) XCTAssertEqual(timerEngine.timerStates.count, 0)
} }
func testSystemSleepManagerHandlesWake() { func testSystemSleepManagerHandlesWake() {