pass 1
This commit is contained in:
@@ -19,26 +19,3 @@ struct SystemTimeProvider: TimeProviding {
|
|||||||
Date()
|
Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test implementation that allows manual time control
|
|
||||||
final class MockTimeProvider: TimeProviding, @unchecked Sendable {
|
|
||||||
private var currentTime: Date
|
|
||||||
|
|
||||||
init(startTime: Date = Date()) {
|
|
||||||
self.currentTime = startTime
|
|
||||||
}
|
|
||||||
|
|
||||||
func now() -> Date {
|
|
||||||
currentTime
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advances time by the specified interval
|
|
||||||
func advance(by interval: TimeInterval) {
|
|
||||||
currentTime = currentTime.addingTimeInterval(interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the current time to a specific date
|
|
||||||
func setTime(_ date: Date) {
|
|
||||||
currentTime = date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
95
Gaze/Services/EnforceMode/EnforceCameraController.swift
Normal file
95
Gaze/Services/EnforceMode/EnforceCameraController.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// EnforceCameraController.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Manages camera lifecycle for enforce mode sessions.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol EnforceCameraControllerDelegate: AnyObject {
|
||||||
|
func cameraControllerDidTimeout(_ controller: EnforceCameraController)
|
||||||
|
func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class EnforceCameraController: ObservableObject {
|
||||||
|
@Published private(set) var isCameraActive = false
|
||||||
|
@Published private(set) var lastFaceDetectionTime: Date = .distantPast
|
||||||
|
|
||||||
|
weak var delegate: EnforceCameraControllerDelegate?
|
||||||
|
|
||||||
|
private let eyeTrackingService: EyeTrackingService
|
||||||
|
private var cancellables = Set<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) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.checkFaceDetectionTimeout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopFaceDetectionTimer() {
|
||||||
|
faceDetectionTimer?.invalidate()
|
||||||
|
faceDetectionTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkFaceDetectionTimeout() {
|
||||||
|
guard isCameraActive else {
|
||||||
|
stopFaceDetectionTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
|
||||||
|
if timeSinceLastDetection > faceDetectionTimeout {
|
||||||
|
delegate?.cameraControllerDidTimeout(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift
Normal file
53
Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//
|
||||||
|
// EnforcePolicyEvaluator.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Policy evaluation for enforce mode behavior.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ComplianceResult {
|
||||||
|
case compliant
|
||||||
|
case notCompliant
|
||||||
|
case faceNotDetected
|
||||||
|
}
|
||||||
|
|
||||||
|
final class EnforcePolicyEvaluator {
|
||||||
|
private let settingsProvider: any SettingsProviding
|
||||||
|
|
||||||
|
init(settingsProvider: any SettingsProviding) {
|
||||||
|
self.settingsProvider = settingsProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEnforcementEnabled: Bool {
|
||||||
|
settingsProvider.settings.enforcementMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool {
|
||||||
|
guard isEnforcementEnabled else { return false }
|
||||||
|
|
||||||
|
switch timerIdentifier {
|
||||||
|
case .builtIn(let type):
|
||||||
|
return type == .lookAway
|
||||||
|
case .user:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldPreActivateCamera(
|
||||||
|
timerIdentifier: TimerIdentifier,
|
||||||
|
secondsRemaining: Int
|
||||||
|
) -> Bool {
|
||||||
|
guard secondsRemaining <= 3 else { return false }
|
||||||
|
return shouldEnforce(timerIdentifier: timerIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateCompliance(
|
||||||
|
isLookingAtScreen: Bool,
|
||||||
|
faceDetected: Bool
|
||||||
|
) -> ComplianceResult {
|
||||||
|
guard faceDetected else { return .faceNotDetected }
|
||||||
|
return isLookingAtScreen ? .notCompliant : .compliant
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,39 +18,21 @@ class EnforceModeService: ObservableObject {
|
|||||||
@Published var isTestMode = false
|
@Published var isTestMode = false
|
||||||
|
|
||||||
private var settingsManager: SettingsManager
|
private var settingsManager: SettingsManager
|
||||||
private var eyeTrackingService: EyeTrackingService
|
private let policyEvaluator: EnforcePolicyEvaluator
|
||||||
|
private let cameraController: EnforceCameraController
|
||||||
private var timerEngine: TimerEngine?
|
private var timerEngine: TimerEngine?
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
private var faceDetectionTimer: Timer?
|
|
||||||
private var lastFaceDetectionTime: Date = Date.distantPast
|
|
||||||
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.policyEvaluator = EnforcePolicyEvaluator(settingsProvider: SettingsManager.shared)
|
||||||
setupObservers()
|
self.cameraController = EnforceCameraController(eyeTrackingService: EyeTrackingService.shared)
|
||||||
|
self.cameraController.delegate = self
|
||||||
initializeEnforceModeState()
|
initializeEnforceModeState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupObservers() {
|
|
||||||
eyeTrackingService.$userLookingAtScreen
|
|
||||||
.sink { [weak self] lookingAtScreen in
|
|
||||||
self?.handleGazeChange(lookingAtScreen: lookingAtScreen)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Observe face detection changes to track person presence
|
|
||||||
eyeTrackingService.$faceDetected
|
|
||||||
.sink { [weak self] faceDetected in
|
|
||||||
self?.handleFaceDetectionChange(faceDetected: faceDetected)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initializeEnforceModeState() {
|
private func initializeEnforceModeState() {
|
||||||
let cameraService = CameraAccessService.shared
|
let cameraService = CameraAccessService.shared
|
||||||
let settingsEnabled = settingsManager.settings.enforcementMode
|
let settingsEnabled = policyEvaluator.isEnforcementEnabled
|
||||||
|
|
||||||
// If settings say it's enabled AND camera is authorized, mark as enabled
|
// If settings say it's enabled AND camera is authorized, mark as enabled
|
||||||
if settingsEnabled && cameraService.isCameraAuthorized {
|
if settingsEnabled && cameraService.isCameraAuthorized {
|
||||||
@@ -104,27 +86,17 @@ class EnforceModeService: ObservableObject {
|
|||||||
|
|
||||||
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 }
|
return policyEvaluator.shouldEnforce(timerIdentifier: timerIdentifier)
|
||||||
|
|
||||||
switch timerIdentifier {
|
|
||||||
case .builtIn(let type):
|
|
||||||
return type == .lookAway
|
|
||||||
case .user:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCameraForLookawayTimer(secondsRemaining: Int) async {
|
func startCameraForLookawayTimer(secondsRemaining: Int) async {
|
||||||
guard isEnforceModeEnabled else { return }
|
guard isEnforceModeEnabled else { return }
|
||||||
guard !isCameraActive else { return }
|
|
||||||
|
|
||||||
logDebug("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
|
logDebug("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await eyeTrackingService.startEyeTracking()
|
try await cameraController.startCamera()
|
||||||
isCameraActive = true
|
isCameraActive = cameraController.isCameraActive
|
||||||
lastFaceDetectionTime = Date() // Reset grace period
|
|
||||||
startFaceDetectionTimer()
|
|
||||||
logDebug("✓ Camera active")
|
logDebug("✓ Camera active")
|
||||||
} catch {
|
} catch {
|
||||||
logError("⚠️ Failed to start camera: \(error.localizedDescription)")
|
logError("⚠️ Failed to start camera: \(error.localizedDescription)")
|
||||||
@@ -135,11 +107,9 @@ class EnforceModeService: ObservableObject {
|
|||||||
guard isCameraActive else { return }
|
guard isCameraActive else { return }
|
||||||
|
|
||||||
logDebug("👁️ Stopping camera")
|
logDebug("👁️ Stopping camera")
|
||||||
eyeTrackingService.stopEyeTracking()
|
cameraController.stopCamera()
|
||||||
isCameraActive = false
|
isCameraActive = false
|
||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
|
|
||||||
stopFaceDetectionTimer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkUserCompliance() {
|
func checkUserCompliance() {
|
||||||
@@ -147,54 +117,18 @@ class EnforceModeService: ObservableObject {
|
|||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let compliance = policyEvaluator.evaluateCompliance(
|
||||||
|
isLookingAtScreen: EyeTrackingService.shared.userLookingAtScreen,
|
||||||
|
faceDetected: EyeTrackingService.shared.faceDetected
|
||||||
|
)
|
||||||
|
|
||||||
let lookingAway = !eyeTrackingService.userLookingAtScreen
|
switch compliance {
|
||||||
userCompliedWithBreak = lookingAway
|
case .compliant:
|
||||||
}
|
userCompliedWithBreak = true
|
||||||
|
case .notCompliant:
|
||||||
private func handleGazeChange(lookingAtScreen: Bool) {
|
userCompliedWithBreak = false
|
||||||
guard isCameraActive else { return }
|
case .faceNotDetected:
|
||||||
|
userCompliedWithBreak = false
|
||||||
checkUserCompliance()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleFaceDetectionChange(faceDetected: Bool) {
|
|
||||||
// Update the last face detection time only when a face is actively detected
|
|
||||||
if faceDetected {
|
|
||||||
lastFaceDetectionTime = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startFaceDetectionTimer() {
|
|
||||||
stopFaceDetectionTimer()
|
|
||||||
// Check every 1 second
|
|
||||||
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) {
|
|
||||||
[weak self] _ in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.checkFaceDetectionTimeout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopFaceDetectionTimer() {
|
|
||||||
faceDetectionTimer?.invalidate()
|
|
||||||
faceDetectionTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkFaceDetectionTimeout() {
|
|
||||||
guard isEnforceModeEnabled && isCameraActive else {
|
|
||||||
stopFaceDetectionTimer()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
|
|
||||||
|
|
||||||
// If person has not been detected for too long, temporarily disable enforce mode
|
|
||||||
if timeSinceLastDetection > faceDetectionTimeout {
|
|
||||||
logDebug(
|
|
||||||
"⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode."
|
|
||||||
)
|
|
||||||
disableEnforceMode()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,16 +142,13 @@ class EnforceModeService: ObservableObject {
|
|||||||
|
|
||||||
func startTestMode() async {
|
func startTestMode() async {
|
||||||
guard isEnforceModeEnabled else { return }
|
guard isEnforceModeEnabled else { return }
|
||||||
guard !isCameraActive else { return }
|
|
||||||
|
|
||||||
logDebug("🧪 Starting test mode")
|
logDebug("🧪 Starting test mode")
|
||||||
isTestMode = true
|
isTestMode = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await eyeTrackingService.startEyeTracking()
|
try await cameraController.startCamera()
|
||||||
isCameraActive = true
|
isCameraActive = cameraController.isCameraActive
|
||||||
lastFaceDetectionTime = Date() // Reset grace period
|
|
||||||
startFaceDetectionTimer()
|
|
||||||
logDebug("✓ Test mode camera active")
|
logDebug("✓ Test mode camera active")
|
||||||
} catch {
|
} catch {
|
||||||
logError("⚠️ Failed to start test mode camera: \(error.localizedDescription)")
|
logError("⚠️ Failed to start test mode camera: \(error.localizedDescription)")
|
||||||
@@ -233,3 +164,17 @@ class EnforceModeService: ObservableObject {
|
|||||||
isTestMode = false
|
isTestMode = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension EnforceModeService: EnforceCameraControllerDelegate {
|
||||||
|
func cameraControllerDidTimeout(_ controller: EnforceCameraController) {
|
||||||
|
logDebug(
|
||||||
|
"⏰ Person not detected for \(controller.faceDetectionTimeout)s. Temporarily disabling enforce mode."
|
||||||
|
)
|
||||||
|
disableEnforceMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool) {
|
||||||
|
guard isCameraActive else { return }
|
||||||
|
checkUserCompliance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
36
Gaze/Services/EyeTracking/CalibrationBridge.swift
Normal file
36
Gaze/Services/EyeTracking/CalibrationBridge.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// CalibrationBridge.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Thread-safe calibration access for eye tracking.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class CalibrationBridge: @unchecked Sendable {
|
||||||
|
nonisolated var thresholds: GazeThresholds? {
|
||||||
|
CalibrationState.shared.thresholds
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var isComplete: Bool {
|
||||||
|
CalibrationState.shared.isComplete
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func submitSample(
|
||||||
|
leftRatio: Double,
|
||||||
|
rightRatio: Double,
|
||||||
|
leftVertical: Double?,
|
||||||
|
rightVertical: Double?,
|
||||||
|
faceWidthRatio: Double
|
||||||
|
) {
|
||||||
|
Task { @MainActor in
|
||||||
|
CalibrationManager.shared.collectSample(
|
||||||
|
leftRatio: leftRatio,
|
||||||
|
rightRatio: rightRatio,
|
||||||
|
leftVertical: leftVertical,
|
||||||
|
rightVertical: rightVertical,
|
||||||
|
faceWidthRatio: faceWidthRatio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
Gaze/Services/EyeTracking/CameraSessionManager.swift
Normal file
123
Gaze/Services/EyeTracking/CameraSessionManager.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
//
|
||||||
|
// CameraSessionManager.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Manages AVCaptureSession lifecycle for eye tracking.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol CameraSessionDelegate: AnyObject {
|
||||||
|
nonisolated func cameraSession(
|
||||||
|
_ manager: CameraSessionManager,
|
||||||
|
didOutput pixelBuffer: CVPixelBuffer,
|
||||||
|
imageSize: CGSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CameraSessionManager: NSObject, ObservableObject {
|
||||||
|
@Published private(set) var isRunning = false
|
||||||
|
weak var delegate: CameraSessionDelegate?
|
||||||
|
|
||||||
|
private var captureSession: AVCaptureSession?
|
||||||
|
private var videoOutput: AVCaptureVideoDataOutput?
|
||||||
|
private let videoDataOutputQueue = DispatchQueue(
|
||||||
|
label: "com.gaze.videoDataOutput",
|
||||||
|
qos: .userInitiated
|
||||||
|
)
|
||||||
|
private var _previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer? {
|
||||||
|
guard let session = captureSession else {
|
||||||
|
_previewLayer = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let existing = _previewLayer, existing.session === session {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
_previewLayer = layer
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func start() async throws {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
|
||||||
|
let cameraService = CameraAccessService.shared
|
||||||
|
if !cameraService.isCameraAuthorized {
|
||||||
|
try await cameraService.requestCameraAccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard cameraService.isCameraAuthorized else {
|
||||||
|
throw CameraAccessError.accessDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
try setupCaptureSession()
|
||||||
|
captureSession?.startRunning()
|
||||||
|
isRunning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func stop() {
|
||||||
|
captureSession?.stopRunning()
|
||||||
|
captureSession = nil
|
||||||
|
videoOutput = nil
|
||||||
|
_previewLayer = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCaptureSession() throws {
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
session.sessionPreset = .vga640x480
|
||||||
|
|
||||||
|
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
|
||||||
|
throw EyeTrackingError.noCamera
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||||
|
guard session.canAddInput(videoInput) else {
|
||||||
|
throw EyeTrackingError.cannotAddInput
|
||||||
|
}
|
||||||
|
session.addInput(videoInput)
|
||||||
|
|
||||||
|
let output = AVCaptureVideoDataOutput()
|
||||||
|
output.videoSettings = [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
|
||||||
|
]
|
||||||
|
output.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
|
||||||
|
output.alwaysDiscardsLateVideoFrames = true
|
||||||
|
|
||||||
|
guard session.canAddOutput(output) else {
|
||||||
|
throw EyeTrackingError.cannotAddOutput
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
|
||||||
|
self.captureSession = session
|
||||||
|
self.videoOutput = output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraSessionManager: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
nonisolated func captureOutput(
|
||||||
|
_ output: AVCaptureOutput,
|
||||||
|
didOutput sampleBuffer: CMSampleBuffer,
|
||||||
|
from connection: AVCaptureConnection
|
||||||
|
) {
|
||||||
|
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = CGSize(
|
||||||
|
width: CVPixelBufferGetWidth(pixelBuffer),
|
||||||
|
height: CVPixelBufferGetHeight(pixelBuffer)
|
||||||
|
)
|
||||||
|
|
||||||
|
delegate?.cameraSession(self, didOutput: pixelBuffer, imageSize: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift
Normal file
101
Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// EyeDebugStateAdapter.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Debug state storage for eye tracking UI.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class EyeDebugStateAdapter {
|
||||||
|
var leftPupilRatio: Double?
|
||||||
|
var rightPupilRatio: Double?
|
||||||
|
var leftVerticalRatio: Double?
|
||||||
|
var rightVerticalRatio: Double?
|
||||||
|
var yaw: Double?
|
||||||
|
var pitch: Double?
|
||||||
|
var enableDebugLogging: Bool = false {
|
||||||
|
didSet {
|
||||||
|
PupilDetector.enableDiagnosticLogging = enableDebugLogging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var leftEyeInput: NSImage?
|
||||||
|
var rightEyeInput: NSImage?
|
||||||
|
var leftEyeProcessed: NSImage?
|
||||||
|
var rightEyeProcessed: NSImage?
|
||||||
|
var leftPupilPosition: PupilPosition?
|
||||||
|
var rightPupilPosition: PupilPosition?
|
||||||
|
var leftEyeSize: CGSize?
|
||||||
|
var rightEyeSize: CGSize?
|
||||||
|
var leftEyeRegion: EyeRegion?
|
||||||
|
var rightEyeRegion: EyeRegion?
|
||||||
|
var imageSize: CGSize?
|
||||||
|
|
||||||
|
var gazeDirection: GazeDirection {
|
||||||
|
guard let leftH = leftPupilRatio,
|
||||||
|
let rightH = rightPupilRatio,
|
||||||
|
let leftV = leftVerticalRatio,
|
||||||
|
let rightV = rightVerticalRatio else {
|
||||||
|
return .center
|
||||||
|
}
|
||||||
|
|
||||||
|
let avgHorizontal = (leftH + rightH) / 2.0
|
||||||
|
let avgVertical = (leftV + rightV) / 2.0
|
||||||
|
|
||||||
|
return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(from result: EyeTrackingProcessingResult) {
|
||||||
|
leftPupilRatio = result.leftPupilRatio
|
||||||
|
rightPupilRatio = result.rightPupilRatio
|
||||||
|
leftVerticalRatio = result.leftVerticalRatio
|
||||||
|
rightVerticalRatio = result.rightVerticalRatio
|
||||||
|
yaw = result.yaw
|
||||||
|
pitch = result.pitch
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEyeImages(from detector: PupilDetector.Type) {
|
||||||
|
if let leftInput = detector.debugLeftEyeInput {
|
||||||
|
leftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height))
|
||||||
|
}
|
||||||
|
if let rightInput = detector.debugRightEyeInput {
|
||||||
|
rightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height))
|
||||||
|
}
|
||||||
|
if let leftProcessed = detector.debugLeftEyeProcessed {
|
||||||
|
leftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height))
|
||||||
|
}
|
||||||
|
if let rightProcessed = detector.debugRightEyeProcessed {
|
||||||
|
rightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height))
|
||||||
|
}
|
||||||
|
leftPupilPosition = detector.debugLeftPupilPosition
|
||||||
|
rightPupilPosition = detector.debugRightPupilPosition
|
||||||
|
leftEyeSize = detector.debugLeftEyeSize
|
||||||
|
rightEyeSize = detector.debugRightEyeSize
|
||||||
|
leftEyeRegion = detector.debugLeftEyeRegion
|
||||||
|
rightEyeRegion = detector.debugRightEyeRegion
|
||||||
|
imageSize = detector.debugImageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
leftPupilRatio = nil
|
||||||
|
rightPupilRatio = nil
|
||||||
|
leftVerticalRatio = nil
|
||||||
|
rightVerticalRatio = nil
|
||||||
|
yaw = nil
|
||||||
|
pitch = nil
|
||||||
|
leftEyeInput = nil
|
||||||
|
rightEyeInput = nil
|
||||||
|
leftEyeProcessed = nil
|
||||||
|
rightEyeProcessed = nil
|
||||||
|
leftPupilPosition = nil
|
||||||
|
rightPupilPosition = nil
|
||||||
|
leftEyeSize = nil
|
||||||
|
rightEyeSize = nil
|
||||||
|
leftEyeRegion = nil
|
||||||
|
rightEyeRegion = nil
|
||||||
|
imageSize = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift
Normal file
21
Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// EyeTrackingProcessingResult.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Shared processing result for eye tracking pipeline.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct EyeTrackingProcessingResult: Sendable {
|
||||||
|
let faceDetected: Bool
|
||||||
|
let isEyesClosed: Bool
|
||||||
|
let userLookingAtScreen: Bool
|
||||||
|
let leftPupilRatio: Double?
|
||||||
|
let rightPupilRatio: Double?
|
||||||
|
let leftVerticalRatio: Double?
|
||||||
|
let rightVerticalRatio: Double?
|
||||||
|
let yaw: Double?
|
||||||
|
let pitch: Double?
|
||||||
|
let faceWidthRatio: Double?
|
||||||
|
}
|
||||||
331
Gaze/Services/EyeTracking/GazeDetector.swift
Normal file
331
Gaze/Services/EyeTracking/GazeDetector.swift
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//
|
||||||
|
// GazeDetector.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Gaze detection logic and pupil analysis.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
import simd
|
||||||
|
|
||||||
|
final class GazeDetector: @unchecked Sendable {
|
||||||
|
struct GazeResult: Sendable {
|
||||||
|
let isLookingAway: Bool
|
||||||
|
let isEyesClosed: Bool
|
||||||
|
let leftPupilRatio: Double?
|
||||||
|
let rightPupilRatio: Double?
|
||||||
|
let leftVerticalRatio: Double?
|
||||||
|
let rightVerticalRatio: Double?
|
||||||
|
let yaw: Double?
|
||||||
|
let pitch: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Configuration: Sendable {
|
||||||
|
let thresholds: GazeThresholds?
|
||||||
|
let isCalibrationComplete: Bool
|
||||||
|
let eyeClosedEnabled: Bool
|
||||||
|
let eyeClosedThreshold: CGFloat
|
||||||
|
let yawEnabled: Bool
|
||||||
|
let yawThreshold: Double
|
||||||
|
let pitchUpEnabled: Bool
|
||||||
|
let pitchUpThreshold: Double
|
||||||
|
let pitchDownEnabled: Bool
|
||||||
|
let pitchDownThreshold: Double
|
||||||
|
let pixelGazeEnabled: Bool
|
||||||
|
let pixelGazeMinRatio: Double
|
||||||
|
let pixelGazeMaxRatio: Double
|
||||||
|
let boundaryForgivenessMargin: Double
|
||||||
|
let distanceSensitivity: Double
|
||||||
|
let defaultReferenceFaceWidth: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var configuration: Configuration
|
||||||
|
|
||||||
|
init(configuration: Configuration) {
|
||||||
|
self.configuration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateConfiguration(_ configuration: Configuration) {
|
||||||
|
lock.lock()
|
||||||
|
self.configuration = configuration
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func process(
|
||||||
|
analysis: VisionPipeline.FaceAnalysis,
|
||||||
|
pixelBuffer: CVPixelBuffer
|
||||||
|
) -> EyeTrackingProcessingResult {
|
||||||
|
let config: Configuration
|
||||||
|
lock.lock()
|
||||||
|
config = configuration
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
guard analysis.faceDetected, let face = analysis.face else {
|
||||||
|
return EyeTrackingProcessingResult(
|
||||||
|
faceDetected: false,
|
||||||
|
isEyesClosed: false,
|
||||||
|
userLookingAtScreen: false,
|
||||||
|
leftPupilRatio: nil,
|
||||||
|
rightPupilRatio: nil,
|
||||||
|
leftVerticalRatio: nil,
|
||||||
|
rightVerticalRatio: nil,
|
||||||
|
yaw: analysis.debugYaw,
|
||||||
|
pitch: analysis.debugPitch,
|
||||||
|
faceWidthRatio: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let landmarks = face.landmarks
|
||||||
|
let yaw = face.yaw?.doubleValue ?? 0.0
|
||||||
|
let pitch = face.pitch?.doubleValue ?? 0.0
|
||||||
|
|
||||||
|
var isEyesClosed = false
|
||||||
|
if let leftEye = landmarks?.leftEye, let rightEye = landmarks?.rightEye {
|
||||||
|
isEyesClosed = detectEyesClosed(leftEye: leftEye, rightEye: rightEye, configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
let gazeResult = detectLookingAway(
|
||||||
|
face: face,
|
||||||
|
landmarks: landmarks,
|
||||||
|
imageSize: analysis.imageSize,
|
||||||
|
pixelBuffer: pixelBuffer,
|
||||||
|
configuration: config
|
||||||
|
)
|
||||||
|
|
||||||
|
let lookingAway = gazeResult.lookingAway
|
||||||
|
let userLookingAtScreen = !lookingAway
|
||||||
|
|
||||||
|
return EyeTrackingProcessingResult(
|
||||||
|
faceDetected: true,
|
||||||
|
isEyesClosed: isEyesClosed,
|
||||||
|
userLookingAtScreen: userLookingAtScreen,
|
||||||
|
leftPupilRatio: gazeResult.leftPupilRatio,
|
||||||
|
rightPupilRatio: gazeResult.rightPupilRatio,
|
||||||
|
leftVerticalRatio: gazeResult.leftVerticalRatio,
|
||||||
|
rightVerticalRatio: gazeResult.rightVerticalRatio,
|
||||||
|
yaw: gazeResult.yaw ?? yaw,
|
||||||
|
pitch: gazeResult.pitch ?? pitch,
|
||||||
|
faceWidthRatio: face.boundingBox.width
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectEyesClosed(
|
||||||
|
leftEye: VNFaceLandmarkRegion2D,
|
||||||
|
rightEye: VNFaceLandmarkRegion2D,
|
||||||
|
configuration: Configuration
|
||||||
|
) -> Bool {
|
||||||
|
guard configuration.eyeClosedEnabled else { return false }
|
||||||
|
guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { return false }
|
||||||
|
|
||||||
|
let leftEyeHeight = calculateEyeHeight(leftEye)
|
||||||
|
let rightEyeHeight = calculateEyeHeight(rightEye)
|
||||||
|
let closedThreshold = configuration.eyeClosedThreshold
|
||||||
|
|
||||||
|
return leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateEyeHeight(_ eye: VNFaceLandmarkRegion2D) -> CGFloat {
|
||||||
|
let points = eye.normalizedPoints
|
||||||
|
guard points.count >= 2 else { return 0 }
|
||||||
|
|
||||||
|
let yValues = points.map { $0.y }
|
||||||
|
let maxY = yValues.max() ?? 0
|
||||||
|
let minY = yValues.min() ?? 0
|
||||||
|
|
||||||
|
return abs(maxY - minY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GazeDetectionResult: Sendable {
|
||||||
|
var lookingAway: Bool = false
|
||||||
|
var leftPupilRatio: Double?
|
||||||
|
var rightPupilRatio: Double?
|
||||||
|
var leftVerticalRatio: Double?
|
||||||
|
var rightVerticalRatio: Double?
|
||||||
|
var yaw: Double?
|
||||||
|
var pitch: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectLookingAway(
|
||||||
|
face: VNFaceObservation,
|
||||||
|
landmarks: VNFaceLandmarks2D?,
|
||||||
|
imageSize: CGSize,
|
||||||
|
pixelBuffer: CVPixelBuffer,
|
||||||
|
configuration: Configuration
|
||||||
|
) -> GazeDetectionResult {
|
||||||
|
var result = GazeDetectionResult()
|
||||||
|
|
||||||
|
let yaw = face.yaw?.doubleValue ?? 0.0
|
||||||
|
let pitch = face.pitch?.doubleValue ?? 0.0
|
||||||
|
result.yaw = yaw
|
||||||
|
result.pitch = pitch
|
||||||
|
|
||||||
|
var poseLookingAway = false
|
||||||
|
|
||||||
|
if face.pitch != nil {
|
||||||
|
if configuration.yawEnabled {
|
||||||
|
let yawThreshold = configuration.yawThreshold
|
||||||
|
if abs(yaw) > yawThreshold {
|
||||||
|
poseLookingAway = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !poseLookingAway {
|
||||||
|
var pitchLookingAway = false
|
||||||
|
|
||||||
|
if configuration.pitchUpEnabled && pitch > configuration.pitchUpThreshold {
|
||||||
|
pitchLookingAway = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if configuration.pitchDownEnabled && pitch < configuration.pitchDownThreshold {
|
||||||
|
pitchLookingAway = true
|
||||||
|
}
|
||||||
|
|
||||||
|
poseLookingAway = pitchLookingAway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var eyesLookingAway = false
|
||||||
|
|
||||||
|
if let landmarks,
|
||||||
|
let leftEye = landmarks.leftEye,
|
||||||
|
let rightEye = landmarks.rightEye,
|
||||||
|
configuration.pixelGazeEnabled {
|
||||||
|
var leftGazeRatio: Double? = nil
|
||||||
|
var rightGazeRatio: Double? = nil
|
||||||
|
var leftVerticalRatio: Double? = nil
|
||||||
|
var rightVerticalRatio: Double? = nil
|
||||||
|
|
||||||
|
if let leftResult = PupilDetector.detectPupil(
|
||||||
|
in: pixelBuffer,
|
||||||
|
eyeLandmarks: leftEye,
|
||||||
|
faceBoundingBox: face.boundingBox,
|
||||||
|
imageSize: imageSize,
|
||||||
|
side: 0
|
||||||
|
) {
|
||||||
|
leftGazeRatio = calculateGazeRatio(
|
||||||
|
pupilPosition: leftResult.pupilPosition,
|
||||||
|
eyeRegion: leftResult.eyeRegion
|
||||||
|
)
|
||||||
|
leftVerticalRatio = calculateVerticalRatio(
|
||||||
|
pupilPosition: leftResult.pupilPosition,
|
||||||
|
eyeRegion: leftResult.eyeRegion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let rightResult = PupilDetector.detectPupil(
|
||||||
|
in: pixelBuffer,
|
||||||
|
eyeLandmarks: rightEye,
|
||||||
|
faceBoundingBox: face.boundingBox,
|
||||||
|
imageSize: imageSize,
|
||||||
|
side: 1
|
||||||
|
) {
|
||||||
|
rightGazeRatio = calculateGazeRatio(
|
||||||
|
pupilPosition: rightResult.pupilPosition,
|
||||||
|
eyeRegion: rightResult.eyeRegion
|
||||||
|
)
|
||||||
|
rightVerticalRatio = calculateVerticalRatio(
|
||||||
|
pupilPosition: rightResult.pupilPosition,
|
||||||
|
eyeRegion: rightResult.eyeRegion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.leftPupilRatio = leftGazeRatio
|
||||||
|
result.rightPupilRatio = rightGazeRatio
|
||||||
|
result.leftVerticalRatio = leftVerticalRatio
|
||||||
|
result.rightVerticalRatio = rightVerticalRatio
|
||||||
|
|
||||||
|
if let leftRatio = leftGazeRatio,
|
||||||
|
let rightRatio = rightGazeRatio {
|
||||||
|
let avgH = (leftRatio + rightRatio) / 2.0
|
||||||
|
let avgV = (leftVerticalRatio != nil && rightVerticalRatio != nil)
|
||||||
|
? (leftVerticalRatio! + rightVerticalRatio!) / 2.0
|
||||||
|
: 0.5
|
||||||
|
|
||||||
|
if configuration.isCalibrationComplete,
|
||||||
|
let thresholds = configuration.thresholds {
|
||||||
|
let currentFaceWidth = face.boundingBox.width
|
||||||
|
let refFaceWidth = thresholds.referenceFaceWidth
|
||||||
|
|
||||||
|
var distanceScale = 1.0
|
||||||
|
if refFaceWidth > 0 && currentFaceWidth > 0 {
|
||||||
|
let rawScale = refFaceWidth / currentFaceWidth
|
||||||
|
distanceScale = 1.0 + (rawScale - 1.0) * configuration.distanceSensitivity
|
||||||
|
distanceScale = max(0.5, min(2.0, distanceScale))
|
||||||
|
}
|
||||||
|
|
||||||
|
let centerH = (thresholds.screenLeftBound + thresholds.screenRightBound) / 2.0
|
||||||
|
let centerV = (thresholds.screenTopBound + thresholds.screenBottomBound) / 2.0
|
||||||
|
|
||||||
|
let deltaH = (avgH - centerH) * distanceScale
|
||||||
|
let deltaV = (avgV - centerV) * distanceScale
|
||||||
|
|
||||||
|
let normalizedH = centerH + deltaH
|
||||||
|
let normalizedV = centerV + deltaV
|
||||||
|
|
||||||
|
let margin = configuration.boundaryForgivenessMargin
|
||||||
|
let isLookingLeft = normalizedH > (thresholds.screenLeftBound + margin)
|
||||||
|
let isLookingRight = normalizedH < (thresholds.screenRightBound - margin)
|
||||||
|
let isLookingUp = normalizedV < (thresholds.screenTopBound - margin)
|
||||||
|
let isLookingDown = normalizedV > (thresholds.screenBottomBound + margin)
|
||||||
|
|
||||||
|
eyesLookingAway = isLookingLeft || isLookingRight || isLookingUp || isLookingDown
|
||||||
|
} else {
|
||||||
|
let currentFaceWidth = face.boundingBox.width
|
||||||
|
let refFaceWidth = configuration.defaultReferenceFaceWidth
|
||||||
|
|
||||||
|
var distanceScale = 1.0
|
||||||
|
if refFaceWidth > 0 && currentFaceWidth > 0 {
|
||||||
|
let rawScale = refFaceWidth / currentFaceWidth
|
||||||
|
distanceScale = 1.0 + (rawScale - 1.0) * configuration.distanceSensitivity
|
||||||
|
distanceScale = max(0.5, min(2.0, distanceScale))
|
||||||
|
}
|
||||||
|
|
||||||
|
let centerH = (configuration.pixelGazeMinRatio + configuration.pixelGazeMaxRatio) / 2.0
|
||||||
|
let normalizedH = centerH + (avgH - centerH) * distanceScale
|
||||||
|
|
||||||
|
let lookingRight = normalizedH <= configuration.pixelGazeMinRatio
|
||||||
|
let lookingLeft = normalizedH >= configuration.pixelGazeMaxRatio
|
||||||
|
eyesLookingAway = lookingRight || lookingLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.lookingAway = poseLookingAway || eyesLookingAway
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateGazeRatio(
|
||||||
|
pupilPosition: PupilPosition,
|
||||||
|
eyeRegion: EyeRegion
|
||||||
|
) -> Double {
|
||||||
|
let pupilX = Double(pupilPosition.x)
|
||||||
|
let eyeCenterX = Double(eyeRegion.center.x)
|
||||||
|
let denominator = (eyeCenterX * 2.0 - 10.0)
|
||||||
|
|
||||||
|
guard denominator > 0 else {
|
||||||
|
let eyeLeft = Double(eyeRegion.frame.minX)
|
||||||
|
let eyeRight = Double(eyeRegion.frame.maxX)
|
||||||
|
let eyeWidth = eyeRight - eyeLeft
|
||||||
|
guard eyeWidth > 0 else { return 0.5 }
|
||||||
|
return (pupilX - eyeLeft) / eyeWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratio = pupilX / denominator
|
||||||
|
return max(0.0, min(1.0, ratio))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateVerticalRatio(
|
||||||
|
pupilPosition: PupilPosition,
|
||||||
|
eyeRegion: EyeRegion
|
||||||
|
) -> Double {
|
||||||
|
let pupilX = Double(pupilPosition.x)
|
||||||
|
let eyeWidth = Double(eyeRegion.frame.width)
|
||||||
|
|
||||||
|
guard eyeWidth > 0 else { return 0.5 }
|
||||||
|
|
||||||
|
let ratio = pupilX / eyeWidth
|
||||||
|
return max(0.0, min(1.0, ratio))
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Gaze/Services/EyeTracking/VisionPipeline.swift
Normal file
67
Gaze/Services/EyeTracking/VisionPipeline.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// VisionPipeline.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Vision processing pipeline for face detection.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
|
||||||
|
final class VisionPipeline: @unchecked Sendable {
|
||||||
|
struct FaceAnalysis: Sendable {
|
||||||
|
let faceDetected: Bool
|
||||||
|
let face: VNFaceObservation?
|
||||||
|
let imageSize: CGSize
|
||||||
|
let debugYaw: Double?
|
||||||
|
let debugPitch: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func analyze(
|
||||||
|
pixelBuffer: CVPixelBuffer,
|
||||||
|
imageSize: CGSize
|
||||||
|
) -> FaceAnalysis {
|
||||||
|
let request = VNDetectFaceLandmarksRequest()
|
||||||
|
request.revision = VNDetectFaceLandmarksRequestRevision3
|
||||||
|
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
request.constellation = .constellation76Points
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(
|
||||||
|
cvPixelBuffer: pixelBuffer,
|
||||||
|
orientation: .upMirrored,
|
||||||
|
options: [:]
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
} catch {
|
||||||
|
return FaceAnalysis(
|
||||||
|
faceDetected: false,
|
||||||
|
face: nil,
|
||||||
|
imageSize: imageSize,
|
||||||
|
debugYaw: nil,
|
||||||
|
debugPitch: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let face = (request.results as? [VNFaceObservation])?.first else {
|
||||||
|
return FaceAnalysis(
|
||||||
|
faceDetected: false,
|
||||||
|
face: nil,
|
||||||
|
imageSize: imageSize,
|
||||||
|
debugYaw: nil,
|
||||||
|
debugPitch: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FaceAnalysis(
|
||||||
|
faceDetected: true,
|
||||||
|
face: face,
|
||||||
|
imageSize: imageSize,
|
||||||
|
debugYaw: face.yaw?.doubleValue,
|
||||||
|
debugPitch: face.pitch?.doubleValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,9 @@
|
|||||||
// Dependency injection container for managing service instances.
|
// Dependency injection container for managing service instances.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A simple dependency injection container for managing service instances.
|
/// A simple dependency injection container for managing service instances.
|
||||||
/// Supports both production and test configurations.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ServiceContainer {
|
final class ServiceContainer {
|
||||||
|
|
||||||
@@ -34,27 +32,22 @@ final class ServiceContainer {
|
|||||||
/// The usage tracking service
|
/// The usage tracking service
|
||||||
private(set) var usageTrackingService: UsageTrackingService?
|
private(set) var usageTrackingService: UsageTrackingService?
|
||||||
|
|
||||||
/// Whether this container is configured for testing
|
|
||||||
let isTestEnvironment: Bool
|
|
||||||
|
|
||||||
/// Creates a production container with real services
|
/// Creates a production container with real services
|
||||||
private init() {
|
private init() {
|
||||||
self.isTestEnvironment = false
|
|
||||||
self.settingsManager = SettingsManager.shared
|
self.settingsManager = SettingsManager.shared
|
||||||
self.enforceModeService = EnforceModeService.shared
|
self.enforceModeService = EnforceModeService.shared
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a test container with injectable dependencies
|
/// Creates a container with injectable dependencies
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - settingsManager: The settings manager to use
|
/// - settingsManager: The settings manager to use
|
||||||
/// - enforceModeService: The enforce mode service to use
|
/// - enforceModeService: The enforce mode service to use
|
||||||
init(
|
init(
|
||||||
settingsManager: any SettingsProviding,
|
settingsManager: any SettingsProviding,
|
||||||
enforceModeService: EnforceModeService? = nil
|
enforceModeService: EnforceModeService
|
||||||
) {
|
) {
|
||||||
self.isTestEnvironment = true
|
|
||||||
self.settingsManager = settingsManager
|
self.settingsManager = settingsManager
|
||||||
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
|
self.enforceModeService = enforceModeService
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets or creates the timer engine
|
/// Gets or creates the timer engine
|
||||||
@@ -65,17 +58,12 @@ final class ServiceContainer {
|
|||||||
let engine = TimerEngine(
|
let engine = TimerEngine(
|
||||||
settingsManager: settingsManager,
|
settingsManager: settingsManager,
|
||||||
enforceModeService: enforceModeService,
|
enforceModeService: enforceModeService,
|
||||||
timeProvider: isTestEnvironment ? MockTimeProvider() : SystemTimeProvider()
|
timeProvider: SystemTimeProvider()
|
||||||
)
|
)
|
||||||
_timerEngine = engine
|
_timerEngine = engine
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a custom timer engine (useful for testing)
|
|
||||||
func setTimerEngine(_ engine: TimerEngine) {
|
|
||||||
_timerEngine = engine
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets up smart mode services
|
/// Sets up smart mode services
|
||||||
func setupSmartModeServices() {
|
func setupSmartModeServices() {
|
||||||
let settings = settingsManager.settings
|
let settings = settingsManager.settings
|
||||||
@@ -102,85 +90,4 @@ final class ServiceContainer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets the container for testing purposes
|
|
||||||
func reset() {
|
|
||||||
_timerEngine?.stop()
|
|
||||||
_timerEngine = nil
|
|
||||||
fullscreenService = nil
|
|
||||||
idleService = nil
|
|
||||||
usageTrackingService = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new container configured for testing with default mock settings
|
|
||||||
static func forTesting() -> ServiceContainer {
|
|
||||||
forTesting(settings: AppSettings())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new container configured for testing with custom settings
|
|
||||||
static func forTesting(settings: AppSettings) -> ServiceContainer {
|
|
||||||
let mockSettings = MockSettingsManager(settings: settings)
|
|
||||||
return ServiceContainer(settingsManager: mockSettings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A mock settings manager for use in ServiceContainer.forTesting()
|
|
||||||
/// This is a minimal implementation - use the full MockSettingsManager from tests for more features
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
final class MockSettingsManager: SettingsProviding {
|
|
||||||
var settings: AppSettings
|
|
||||||
|
|
||||||
@ObservationIgnored
|
|
||||||
private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
|
|
||||||
|
|
||||||
var settingsPublisher: AnyPublisher<AppSettings, Never> {
|
|
||||||
_settingsSubject.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ObservationIgnored
|
|
||||||
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
|
|
||||||
.lookAway: \.lookAwayTimer,
|
|
||||||
.blink: \.blinkTimer,
|
|
||||||
.posture: \.postureTimer,
|
|
||||||
]
|
|
||||||
|
|
||||||
convenience init() {
|
|
||||||
self.init(settings: AppSettings())
|
|
||||||
}
|
|
||||||
|
|
||||||
init(settings: AppSettings) {
|
|
||||||
self.settings = settings
|
|
||||||
self._settingsSubject = CurrentValueSubject(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
|
|
||||||
guard let keyPath = timerConfigKeyPaths[type] else {
|
|
||||||
preconditionFailure("Unknown timer type: \(type)")
|
|
||||||
}
|
|
||||||
return settings[keyPath: keyPath]
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) {
|
|
||||||
guard let keyPath = timerConfigKeyPaths[type] else {
|
|
||||||
preconditionFailure("Unknown timer type: \(type)")
|
|
||||||
}
|
|
||||||
settings[keyPath: keyPath] = configuration
|
|
||||||
_settingsSubject.send(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
|
|
||||||
var configs: [TimerType: TimerConfiguration] = [:]
|
|
||||||
for (type, keyPath) in timerConfigKeyPaths {
|
|
||||||
configs[type] = settings[keyPath: keyPath]
|
|
||||||
}
|
|
||||||
return configs
|
|
||||||
}
|
|
||||||
|
|
||||||
func save() { _settingsSubject.send(settings) }
|
|
||||||
func saveImmediately() { _settingsSubject.send(settings) }
|
|
||||||
func load() {}
|
|
||||||
func resetToDefaults() {
|
|
||||||
settings = .defaults
|
|
||||||
_settingsSubject.send(settings)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
//
|
|
||||||
// SmartModeManager.swift
|
|
||||||
// Gaze
|
|
||||||
//
|
|
||||||
// Handles smart mode features like idle detection and fullscreen detection.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class SmartModeManager {
|
|
||||||
private var fullscreenService: FullscreenDetectionService?
|
|
||||||
private var idleService: IdleMonitoringService?
|
|
||||||
private var timerEngine: TimerEngine?
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
func setupSmartMode(
|
|
||||||
timerEngine: TimerEngine,
|
|
||||||
fullscreenService: FullscreenDetectionService?,
|
|
||||||
idleService: IdleMonitoringService?
|
|
||||||
) {
|
|
||||||
self.timerEngine = timerEngine
|
|
||||||
self.fullscreenService = fullscreenService
|
|
||||||
self.idleService = idleService
|
|
||||||
|
|
||||||
// Subscribe to fullscreen state changes
|
|
||||||
fullscreenService?.$isFullscreenActive
|
|
||||||
.sink { [weak self] isFullscreen in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handleFullscreenChange(isFullscreen: isFullscreen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Subscribe to idle state changes
|
|
||||||
idleService?.$isIdle
|
|
||||||
.sink { [weak self] isIdle in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handleIdleChange(isIdle: isIdle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleFullscreenChange(isFullscreen: Bool) {
|
|
||||||
guard let timerEngine = timerEngine else { return }
|
|
||||||
guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnFullscreen else { return }
|
|
||||||
|
|
||||||
if isFullscreen {
|
|
||||||
timerEngine.pauseAllTimers(reason: .fullscreen)
|
|
||||||
logInfo("⏸️ Timers paused: fullscreen detected")
|
|
||||||
} else {
|
|
||||||
timerEngine.resumeAllTimers(reason: .fullscreen)
|
|
||||||
logInfo("▶️ Timers resumed: fullscreen exited")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleIdleChange(isIdle: Bool) {
|
|
||||||
guard let timerEngine = timerEngine else { return }
|
|
||||||
guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnIdle else { return }
|
|
||||||
|
|
||||||
if isIdle {
|
|
||||||
timerEngine.pauseAllTimers(reason: .idle)
|
|
||||||
logInfo("⏸️ Timers paused: user idle")
|
|
||||||
} else {
|
|
||||||
timerEngine.resumeAllTimers(reason: .idle)
|
|
||||||
logInfo("▶️ Timers resumed: user active")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
56
Gaze/Services/Timer/ReminderTriggerService.swift
Normal file
56
Gaze/Services/Timer/ReminderTriggerService.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// ReminderTriggerService.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Creates reminder events and coordinates enforce mode behavior.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ReminderTriggerService {
|
||||||
|
private let settingsProvider: any SettingsProviding
|
||||||
|
private let enforceModeService: EnforceModeService?
|
||||||
|
|
||||||
|
init(
|
||||||
|
settingsProvider: any SettingsProviding,
|
||||||
|
enforceModeService: EnforceModeService?
|
||||||
|
) {
|
||||||
|
self.settingsProvider = settingsProvider
|
||||||
|
self.enforceModeService = enforceModeService
|
||||||
|
}
|
||||||
|
|
||||||
|
func reminderEvent(for identifier: TimerIdentifier) -> ReminderEvent? {
|
||||||
|
switch identifier {
|
||||||
|
case .builtIn(let type):
|
||||||
|
switch type {
|
||||||
|
case .lookAway:
|
||||||
|
return .lookAwayTriggered(
|
||||||
|
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds
|
||||||
|
)
|
||||||
|
case .blink:
|
||||||
|
return .blinkTriggered
|
||||||
|
case .posture:
|
||||||
|
return .postureTriggered
|
||||||
|
}
|
||||||
|
case .user(let id):
|
||||||
|
guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .userTimerTriggered(userTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldPrepareEnforceMode(for identifier: TimerIdentifier, secondsRemaining: Int) -> Bool {
|
||||||
|
guard secondsRemaining <= 3 else { return false }
|
||||||
|
return enforceModeService?.shouldEnforceBreak(for: identifier) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareEnforceMode(secondsRemaining: Int) async {
|
||||||
|
await enforceModeService?.startCameraForLookawayTimer(secondsRemaining: secondsRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleReminderDismissed() {
|
||||||
|
enforceModeService?.handleReminderDismissed()
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Gaze/Services/Timer/SmartModeCoordinator.swift
Normal file
85
Gaze/Services/Timer/SmartModeCoordinator.swift
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//
|
||||||
|
// SmartModeCoordinator.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Coordinates smart mode pause/resume behavior.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol SmartModeCoordinatorDelegate: AnyObject {
|
||||||
|
func smartModeDidRequestPauseAll(_ coordinator: SmartModeCoordinator, reason: PauseReason)
|
||||||
|
func smartModeDidRequestResumeAll(_ coordinator: SmartModeCoordinator, reason: PauseReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SmartModeCoordinator {
|
||||||
|
weak var delegate: SmartModeCoordinatorDelegate?
|
||||||
|
|
||||||
|
private var fullscreenService: FullscreenDetectionService?
|
||||||
|
private var idleService: IdleMonitoringService?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var settingsProvider: (any SettingsProviding)?
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
func setup(
|
||||||
|
fullscreenService: FullscreenDetectionService?,
|
||||||
|
idleService: IdleMonitoringService?,
|
||||||
|
settingsProvider: any SettingsProviding
|
||||||
|
) {
|
||||||
|
self.fullscreenService = fullscreenService
|
||||||
|
self.idleService = idleService
|
||||||
|
self.settingsProvider = settingsProvider
|
||||||
|
|
||||||
|
fullscreenService?.$isFullscreenActive
|
||||||
|
.sink { [weak self] isFullscreen in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleFullscreenChange(isFullscreen: isFullscreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
idleService?.$isIdle
|
||||||
|
.sink { [weak self] isIdle in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleIdleChange(isIdle: isIdle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardown() {
|
||||||
|
cancellables.removeAll()
|
||||||
|
fullscreenService = nil
|
||||||
|
idleService = nil
|
||||||
|
settingsProvider = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFullscreenChange(isFullscreen: Bool) {
|
||||||
|
guard let settingsProvider else { return }
|
||||||
|
guard settingsProvider.settings.smartMode.autoPauseOnFullscreen else { return }
|
||||||
|
|
||||||
|
if isFullscreen {
|
||||||
|
delegate?.smartModeDidRequestPauseAll(self, reason: .fullscreen)
|
||||||
|
logInfo("⏸️ Timers paused: fullscreen detected")
|
||||||
|
} else {
|
||||||
|
delegate?.smartModeDidRequestResumeAll(self, reason: .fullscreen)
|
||||||
|
logInfo("▶️ Timers resumed: fullscreen exited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleIdleChange(isIdle: Bool) {
|
||||||
|
guard let settingsProvider else { return }
|
||||||
|
guard settingsProvider.settings.smartMode.autoPauseOnIdle else { return }
|
||||||
|
|
||||||
|
if isIdle {
|
||||||
|
delegate?.smartModeDidRequestPauseAll(self, reason: .idle)
|
||||||
|
logInfo("⏸️ Timers paused: user idle")
|
||||||
|
} else {
|
||||||
|
delegate?.smartModeDidRequestResumeAll(self, reason: .idle)
|
||||||
|
logInfo("▶️ Timers resumed: user active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Gaze/Services/Timer/TimerScheduler.swift
Normal file
44
Gaze/Services/Timer/TimerScheduler.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// TimerScheduler.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Schedules timer ticks for TimerEngine.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol TimerSchedulerDelegate: AnyObject {
|
||||||
|
func schedulerDidTick(_ scheduler: TimerScheduler)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TimerScheduler {
|
||||||
|
weak var delegate: TimerSchedulerDelegate?
|
||||||
|
|
||||||
|
private var timerSubscription: AnyCancellable?
|
||||||
|
private let timeProvider: TimeProviding
|
||||||
|
|
||||||
|
init(timeProvider: TimeProviding) {
|
||||||
|
self.timeProvider = timeProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRunning: Bool {
|
||||||
|
timerSubscription != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard timerSubscription == nil else { return }
|
||||||
|
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||||
|
.autoconnect()
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.schedulerDidTick(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
timerSubscription?.cancel()
|
||||||
|
timerSubscription = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
162
Gaze/Services/Timer/TimerStateManager.swift
Normal file
162
Gaze/Services/Timer/TimerStateManager.swift
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
//
|
||||||
|
// TimerStateManager.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Manages timer state transitions and reminder state.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TimerStateManager: ObservableObject {
|
||||||
|
@Published private(set) var timerStates: [TimerIdentifier: TimerState] = [:]
|
||||||
|
@Published private(set) var activeReminder: ReminderEvent?
|
||||||
|
|
||||||
|
func initializeTimers(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) {
|
||||||
|
var newStates: [TimerIdentifier: TimerState] = [:]
|
||||||
|
|
||||||
|
for (identifier, config) in configurations where config.enabled {
|
||||||
|
newStates[identifier] = TimerState(
|
||||||
|
identifier: identifier,
|
||||||
|
intervalSeconds: config.intervalSeconds,
|
||||||
|
isPaused: false,
|
||||||
|
isActive: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for userTimer in userTimers where userTimer.enabled {
|
||||||
|
let identifier = TimerIdentifier.user(id: userTimer.id)
|
||||||
|
newStates[identifier] = TimerState(
|
||||||
|
identifier: identifier,
|
||||||
|
intervalSeconds: userTimer.intervalMinutes * 60,
|
||||||
|
isPaused: false,
|
||||||
|
isActive: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
timerStates = newStates
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateConfigurations(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) {
|
||||||
|
var newStates: [TimerIdentifier: TimerState] = [:]
|
||||||
|
|
||||||
|
for (identifier, config) in configurations {
|
||||||
|
if config.enabled {
|
||||||
|
if let existingState = timerStates[identifier] {
|
||||||
|
if existingState.originalIntervalSeconds != config.intervalSeconds {
|
||||||
|
newStates[identifier] = TimerState(
|
||||||
|
identifier: identifier,
|
||||||
|
intervalSeconds: config.intervalSeconds,
|
||||||
|
isPaused: existingState.isPaused,
|
||||||
|
isActive: true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
newStates[identifier] = existingState
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newStates[identifier] = TimerState(
|
||||||
|
identifier: identifier,
|
||||||
|
intervalSeconds: config.intervalSeconds,
|
||||||
|
isPaused: false,
|
||||||
|
isActive: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for userTimer in userTimers {
|
||||||
|
let identifier = TimerIdentifier.user(id: userTimer.id)
|
||||||
|
let newIntervalSeconds = userTimer.intervalMinutes * 60
|
||||||
|
|
||||||
|
if userTimer.enabled {
|
||||||
|
if let existingState = timerStates[identifier] {
|
||||||
|
if existingState.originalIntervalSeconds != newIntervalSeconds {
|
||||||
|
newStates[identifier] = TimerState(
|
||||||
|
identifier: identifier,
|
||||||
|
intervalSeconds: newIntervalSeconds,
|
||||||
|
isPaused: existingState.isPaused,
|
||||||
|
isActive: true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
newStates[identifier] = existingState
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newStates[identifier] = TimerState(
|
||||||
|
identifier: identifier,
|
||||||
|
intervalSeconds: newIntervalSeconds,
|
||||||
|
isPaused: false,
|
||||||
|
isActive: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timerStates = newStates
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrementTimer(identifier: TimerIdentifier) -> TimerState? {
|
||||||
|
guard var state = timerStates[identifier] else { return nil }
|
||||||
|
state.remainingSeconds -= 1
|
||||||
|
timerStates[identifier] = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func setReminder(_ reminder: ReminderEvent?) {
|
||||||
|
activeReminder = reminder
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseAll(reason: PauseReason) {
|
||||||
|
for (id, var state) in timerStates {
|
||||||
|
state.pauseReasons.insert(reason)
|
||||||
|
state.isPaused = true
|
||||||
|
timerStates[id] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeAll(reason: PauseReason) {
|
||||||
|
for (id, var state) in timerStates {
|
||||||
|
state.pauseReasons.remove(reason)
|
||||||
|
state.isPaused = !state.pauseReasons.isEmpty
|
||||||
|
timerStates[id] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseTimer(identifier: TimerIdentifier, reason: PauseReason) {
|
||||||
|
guard var state = timerStates[identifier] else { return }
|
||||||
|
state.pauseReasons.insert(reason)
|
||||||
|
state.isPaused = true
|
||||||
|
timerStates[identifier] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeTimer(identifier: TimerIdentifier, reason: PauseReason) {
|
||||||
|
guard var state = timerStates[identifier] else { return }
|
||||||
|
state.pauseReasons.remove(reason)
|
||||||
|
state.isPaused = !state.pauseReasons.isEmpty
|
||||||
|
timerStates[identifier] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetTimer(identifier: TimerIdentifier, intervalSeconds: Int) {
|
||||||
|
guard let state = timerStates[identifier] else { return }
|
||||||
|
timerStates[identifier] = TimerState(
|
||||||
|
identifier: identifier,
|
||||||
|
intervalSeconds: intervalSeconds,
|
||||||
|
isPaused: state.isPaused,
|
||||||
|
isActive: state.isActive
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
|
||||||
|
guard let state = timerStates[identifier] else { return 0 }
|
||||||
|
return TimeInterval(state.remainingSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
|
||||||
|
return timerStates[identifier]?.isPaused ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
timerStates.removeAll()
|
||||||
|
activeReminder = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,24 +13,12 @@ class TimerEngine: ObservableObject {
|
|||||||
@Published var timerStates: [TimerIdentifier: TimerState] = [:]
|
@Published var timerStates: [TimerIdentifier: TimerState] = [:]
|
||||||
@Published var activeReminder: ReminderEvent?
|
@Published var activeReminder: ReminderEvent?
|
||||||
|
|
||||||
private var timerSubscription: AnyCancellable?
|
|
||||||
private let settingsProvider: any SettingsProviding
|
private let settingsProvider: any SettingsProviding
|
||||||
|
|
||||||
// Expose the settings provider for components that need it (like SmartModeManager)
|
|
||||||
var settingsProviderForTesting: any SettingsProviding {
|
|
||||||
return settingsProvider
|
|
||||||
}
|
|
||||||
private var sleepStartTime: Date?
|
|
||||||
|
|
||||||
/// Time provider for deterministic testing (defaults to system time)
|
|
||||||
private let timeProvider: TimeProviding
|
private let timeProvider: TimeProviding
|
||||||
|
private let stateManager = TimerStateManager()
|
||||||
// For enforce mode integration
|
private let scheduler: TimerScheduler
|
||||||
private var enforceModeService: EnforceModeService?
|
private let reminderService: ReminderTriggerService
|
||||||
|
private let smartModeCoordinator = SmartModeCoordinator()
|
||||||
// Smart Mode services
|
|
||||||
private var fullscreenService: FullscreenDetectionService?
|
|
||||||
private var idleService: IdleMonitoringService?
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
convenience init(
|
convenience init(
|
||||||
@@ -50,127 +38,66 @@ class TimerEngine: ObservableObject {
|
|||||||
timeProvider: TimeProviding
|
timeProvider: TimeProviding
|
||||||
) {
|
) {
|
||||||
self.settingsProvider = settingsManager
|
self.settingsProvider = settingsManager
|
||||||
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
|
|
||||||
self.timeProvider = timeProvider
|
self.timeProvider = timeProvider
|
||||||
|
self.scheduler = TimerScheduler(timeProvider: timeProvider)
|
||||||
|
self.reminderService = ReminderTriggerService(
|
||||||
|
settingsProvider: settingsManager,
|
||||||
|
enforceModeService: enforceModeService ?? EnforceModeService.shared
|
||||||
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.enforceModeService?.setTimerEngine(self)
|
enforceModeService?.setTimerEngine(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduler.delegate = self
|
||||||
|
smartModeCoordinator.delegate = self
|
||||||
|
|
||||||
|
stateManager.$timerStates
|
||||||
|
.sink { [weak self] states in
|
||||||
|
self?.timerStates = states
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
stateManager.$activeReminder
|
||||||
|
.sink { [weak self] reminder in
|
||||||
|
self?.activeReminder = reminder
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupSmartMode(
|
func setupSmartMode(
|
||||||
fullscreenService: FullscreenDetectionService?,
|
fullscreenService: FullscreenDetectionService?,
|
||||||
idleService: IdleMonitoringService?
|
idleService: IdleMonitoringService?
|
||||||
) {
|
) {
|
||||||
self.fullscreenService = fullscreenService
|
smartModeCoordinator.setup(
|
||||||
self.idleService = idleService
|
fullscreenService: fullscreenService,
|
||||||
|
idleService: idleService,
|
||||||
// Subscribe to fullscreen state changes
|
settingsProvider: settingsProvider
|
||||||
fullscreenService?.$isFullscreenActive
|
)
|
||||||
.sink { [weak self] isFullscreen in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handleFullscreenChange(isFullscreen: isFullscreen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Subscribe to idle state changes
|
|
||||||
idleService?.$isIdle
|
|
||||||
.sink { [weak self] isIdle in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handleIdleChange(isIdle: isIdle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleFullscreenChange(isFullscreen: Bool) {
|
|
||||||
guard settingsProvider.settings.smartMode.autoPauseOnFullscreen else { return }
|
|
||||||
|
|
||||||
if isFullscreen {
|
|
||||||
pauseAllTimers(reason: .fullscreen)
|
|
||||||
logInfo("⏸️ Timers paused: fullscreen detected")
|
|
||||||
} else {
|
|
||||||
resumeAllTimers(reason: .fullscreen)
|
|
||||||
logInfo("▶️ Timers resumed: fullscreen exited")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleIdleChange(isIdle: Bool) {
|
|
||||||
guard settingsProvider.settings.smartMode.autoPauseOnIdle else { return }
|
|
||||||
|
|
||||||
if isIdle {
|
|
||||||
pauseAllTimers(reason: .idle)
|
|
||||||
logInfo("⏸️ Timers paused: user idle")
|
|
||||||
} else {
|
|
||||||
resumeAllTimers(reason: .idle)
|
|
||||||
logInfo("▶️ Timers resumed: user active")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pauseAllTimers(reason: PauseReason) {
|
func pauseAllTimers(reason: PauseReason) {
|
||||||
for (id, var state) in timerStates {
|
stateManager.pauseAll(reason: reason)
|
||||||
state.pauseReasons.insert(reason)
|
|
||||||
state.isPaused = true
|
|
||||||
timerStates[id] = state
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resumeAllTimers(reason: PauseReason) {
|
func resumeAllTimers(reason: PauseReason) {
|
||||||
for (id, var state) in timerStates {
|
stateManager.resumeAll(reason: reason)
|
||||||
state.pauseReasons.remove(reason)
|
|
||||||
state.isPaused = !state.pauseReasons.isEmpty
|
|
||||||
timerStates[id] = state
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
// If timers are already running, just update configurations without resetting
|
// If timers are already running, just update configurations without resetting
|
||||||
if timerSubscription != nil {
|
if scheduler.isRunning {
|
||||||
updateConfigurations()
|
updateConfigurations()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial start - create all timer states
|
// Initial start - create all timer states
|
||||||
stop()
|
stop()
|
||||||
|
stateManager.initializeTimers(
|
||||||
var newStates: [TimerIdentifier: TimerState] = [:]
|
using: timerConfigurations(),
|
||||||
|
userTimers: settingsProvider.settings.userTimers
|
||||||
// Add built-in timers (using unified approach)
|
)
|
||||||
for timerType in TimerType.allCases {
|
scheduler.start()
|
||||||
let config = settingsProvider.timerConfiguration(for: timerType)
|
|
||||||
if config.enabled {
|
|
||||||
let identifier = TimerIdentifier.builtIn(timerType)
|
|
||||||
newStates[identifier] = TimerState(
|
|
||||||
identifier: identifier,
|
|
||||||
intervalSeconds: config.intervalSeconds,
|
|
||||||
isPaused: false,
|
|
||||||
isActive: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add user timers (using unified approach)
|
|
||||||
for userTimer in settingsProvider.settings.userTimers where userTimer.enabled {
|
|
||||||
let identifier = TimerIdentifier.user(id: userTimer.id)
|
|
||||||
newStates[identifier] = TimerState(
|
|
||||||
identifier: identifier,
|
|
||||||
intervalSeconds: userTimer.intervalMinutes * 60,
|
|
||||||
isPaused: false,
|
|
||||||
isActive: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign the entire dictionary at once to trigger @Published
|
|
||||||
timerStates = newStates
|
|
||||||
|
|
||||||
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
|
|
||||||
.autoconnect()
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handleTick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if enforce mode is active and should affect timer behavior
|
/// Check if enforce mode is active and should affect timer behavior
|
||||||
@@ -180,130 +107,36 @@ class TimerEngine: ObservableObject {
|
|||||||
|
|
||||||
private func updateConfigurations() {
|
private func updateConfigurations() {
|
||||||
logDebug("Updating timer configurations")
|
logDebug("Updating timer configurations")
|
||||||
var newStates: [TimerIdentifier: TimerState] = [:]
|
stateManager.updateConfigurations(
|
||||||
|
using: timerConfigurations(),
|
||||||
// Update built-in timers (using unified approach)
|
userTimers: settingsProvider.settings.userTimers
|
||||||
for timerType in TimerType.allCases {
|
)
|
||||||
let config = settingsProvider.timerConfiguration(for: timerType)
|
|
||||||
let identifier = TimerIdentifier.builtIn(timerType)
|
|
||||||
|
|
||||||
if config.enabled {
|
|
||||||
if let existingState = timerStates[identifier] {
|
|
||||||
// Timer exists - check if interval changed
|
|
||||||
if existingState.originalIntervalSeconds != config.intervalSeconds {
|
|
||||||
// Interval changed - reset with new interval
|
|
||||||
logDebug("Timer interval changed")
|
|
||||||
newStates[identifier] = TimerState(
|
|
||||||
identifier: identifier,
|
|
||||||
intervalSeconds: config.intervalSeconds,
|
|
||||||
isPaused: existingState.isPaused,
|
|
||||||
isActive: true
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Interval unchanged - keep existing state
|
|
||||||
newStates[identifier] = existingState
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Timer was just enabled - create new state
|
|
||||||
logDebug("Timer enabled")
|
|
||||||
newStates[identifier] = TimerState(
|
|
||||||
identifier: identifier,
|
|
||||||
intervalSeconds: config.intervalSeconds,
|
|
||||||
isPaused: false,
|
|
||||||
isActive: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If config.enabled is false and timer exists, it will be removed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user timers (using unified approach)
|
|
||||||
for userTimer in settingsProvider.settings.userTimers {
|
|
||||||
let identifier = TimerIdentifier.user(id: userTimer.id)
|
|
||||||
let newIntervalSeconds = userTimer.intervalMinutes * 60
|
|
||||||
|
|
||||||
if userTimer.enabled {
|
|
||||||
if let existingState = timerStates[identifier] {
|
|
||||||
// Check if interval changed
|
|
||||||
if existingState.originalIntervalSeconds != newIntervalSeconds {
|
|
||||||
// Interval changed - reset with new interval
|
|
||||||
logDebug("User timer interval changed")
|
|
||||||
newStates[identifier] = TimerState(
|
|
||||||
identifier: identifier,
|
|
||||||
intervalSeconds: newIntervalSeconds,
|
|
||||||
isPaused: existingState.isPaused,
|
|
||||||
isActive: true
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Interval unchanged - keep existing state
|
|
||||||
newStates[identifier] = existingState
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New timer - create state
|
|
||||||
logDebug("User timer created")
|
|
||||||
newStates[identifier] = TimerState(
|
|
||||||
identifier: identifier,
|
|
||||||
intervalSeconds: newIntervalSeconds,
|
|
||||||
isPaused: false,
|
|
||||||
isActive: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If timer is disabled, it will be removed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign the entire dictionary at once to trigger @Published
|
|
||||||
timerStates = newStates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
timerSubscription?.cancel()
|
scheduler.stop()
|
||||||
timerSubscription = nil
|
stateManager.clearAll()
|
||||||
timerStates.removeAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
for (id, var state) in timerStates {
|
stateManager.pauseAll(reason: .manual)
|
||||||
state.pauseReasons.insert(.manual)
|
|
||||||
state.isPaused = true
|
|
||||||
timerStates[id] = state
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resume() {
|
func resume() {
|
||||||
for (id, var state) in timerStates {
|
stateManager.resumeAll(reason: .manual)
|
||||||
state.pauseReasons.remove(.manual)
|
|
||||||
state.isPaused = !state.pauseReasons.isEmpty
|
|
||||||
timerStates[id] = state
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pauseTimer(identifier: TimerIdentifier) {
|
func pauseTimer(identifier: TimerIdentifier) {
|
||||||
guard var state = timerStates[identifier] else { return }
|
stateManager.pauseTimer(identifier: identifier, reason: .manual)
|
||||||
state.pauseReasons.insert(.manual)
|
|
||||||
state.isPaused = true
|
|
||||||
timerStates[identifier] = state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resumeTimer(identifier: TimerIdentifier) {
|
func resumeTimer(identifier: TimerIdentifier) {
|
||||||
guard var state = timerStates[identifier] else { return }
|
stateManager.resumeTimer(identifier: identifier, reason: .manual)
|
||||||
state.pauseReasons.remove(.manual)
|
|
||||||
state.isPaused = !state.pauseReasons.isEmpty
|
|
||||||
timerStates[identifier] = state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func skipNext(identifier: TimerIdentifier) {
|
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)
|
let intervalSeconds = getTimerInterval(for: identifier)
|
||||||
|
stateManager.resetTimer(identifier: identifier, intervalSeconds: intervalSeconds)
|
||||||
timerStates[identifier] = TimerState(
|
|
||||||
identifier: identifier,
|
|
||||||
intervalSeconds: intervalSeconds,
|
|
||||||
isPaused: state.isPaused,
|
|
||||||
isActive: state.isActive
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unified way to get interval for any timer type
|
/// Unified way to get interval for any timer type
|
||||||
@@ -322,13 +155,13 @@ func skipNext(identifier: TimerIdentifier) {
|
|||||||
|
|
||||||
func dismissReminder() {
|
func dismissReminder() {
|
||||||
guard let reminder = activeReminder else { return }
|
guard let reminder = activeReminder else { return }
|
||||||
activeReminder = nil
|
stateManager.setReminder(nil)
|
||||||
|
|
||||||
let identifier = reminder.identifier
|
let identifier = reminder.identifier
|
||||||
skipNext(identifier: identifier)
|
skipNext(identifier: identifier)
|
||||||
resumeTimer(identifier: identifier)
|
resumeTimer(identifier: identifier)
|
||||||
|
|
||||||
enforceModeService?.handleReminderDismissed()
|
reminderService.handleReminderDismissed()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleTick() {
|
private func handleTick() {
|
||||||
@@ -341,24 +174,22 @@ func skipNext(identifier: TimerIdentifier) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
timerStates[identifier]?.remainingSeconds -= 1
|
guard let updatedState = stateManager.decrementTimer(identifier: identifier) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if let updatedState = timerStates[identifier] {
|
if reminderService.shouldPrepareEnforceMode(
|
||||||
// Unified approach - no more special handling needed for any timer type
|
for: identifier,
|
||||||
if updatedState.remainingSeconds <= 3 && !updatedState.isPaused {
|
secondsRemaining: updatedState.remainingSeconds
|
||||||
// Enforce mode is handled generically, not specifically for lookAway only
|
) {
|
||||||
if enforceModeService?.shouldEnforceBreak(for: identifier) == true {
|
Task { @MainActor in
|
||||||
Task { @MainActor in
|
await reminderService.prepareEnforceMode(secondsRemaining: updatedState.remainingSeconds)
|
||||||
await enforceModeService?.startCameraForLookawayTimer(
|
|
||||||
secondsRemaining: updatedState.remainingSeconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if updatedState.remainingSeconds <= 0 {
|
if updatedState.remainingSeconds <= 0 {
|
||||||
triggerReminder(for: identifier)
|
triggerReminder(for: identifier)
|
||||||
break
|
break
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,28 +198,13 @@ func skipNext(identifier: TimerIdentifier) {
|
|||||||
// Pause only the timer that triggered
|
// Pause only the timer that triggered
|
||||||
pauseTimer(identifier: identifier)
|
pauseTimer(identifier: identifier)
|
||||||
|
|
||||||
// Unified approach to handle all timer types - no more special handling
|
if let reminder = reminderService.reminderEvent(for: identifier) {
|
||||||
switch identifier {
|
stateManager.setReminder(reminder)
|
||||||
case .builtIn(let type):
|
|
||||||
switch type {
|
|
||||||
case .lookAway:
|
|
||||||
activeReminder = .lookAwayTriggered(
|
|
||||||
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds)
|
|
||||||
case .blink:
|
|
||||||
activeReminder = .blinkTriggered
|
|
||||||
case .posture:
|
|
||||||
activeReminder = .postureTriggered
|
|
||||||
}
|
|
||||||
case .user(let id):
|
|
||||||
if let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) {
|
|
||||||
activeReminder = .userTimerTriggered(userTimer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
|
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
|
||||||
guard let state = timerStates[identifier] else { return 0 }
|
stateManager.getTimeRemaining(for: identifier)
|
||||||
return TimeInterval(state.remainingSeconds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {
|
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {
|
||||||
@@ -396,11 +212,11 @@ func skipNext(identifier: TimerIdentifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
|
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
|
||||||
return timerStates[identifier]?.isPaused ?? true
|
return stateManager.isTimerPaused(identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// System sleep/wake handling is now managed by SystemSleepManager
|
// System sleep/wake handling is now managed by SystemSleepManager
|
||||||
// This method is kept for compatibility but will be removed in future versions
|
// This method is kept for compatibility but will be removed in future versions
|
||||||
/// Handles system sleep event - deprecated
|
/// Handles system sleep event - deprecated
|
||||||
@available(*, deprecated, message: "Use SystemSleepManager instead")
|
@available(*, deprecated, message: "Use SystemSleepManager instead")
|
||||||
func handleSystemSleep() {
|
func handleSystemSleep() {
|
||||||
@@ -414,5 +230,29 @@ func skipNext(identifier: TimerIdentifier) {
|
|||||||
logDebug("System waking up (deprecated)")
|
logDebug("System waking up (deprecated)")
|
||||||
// This functionality has been moved to SystemSleepManager
|
// This functionality has been moved to SystemSleepManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func timerConfigurations() -> [TimerIdentifier: TimerConfiguration] {
|
||||||
|
var configurations: [TimerIdentifier: TimerConfiguration] = [:]
|
||||||
|
for timerType in TimerType.allCases {
|
||||||
|
let config = settingsProvider.timerConfiguration(for: timerType)
|
||||||
|
configurations[.builtIn(timerType)] = config
|
||||||
|
}
|
||||||
|
return configurations
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension TimerEngine: TimerSchedulerDelegate {
|
||||||
|
func schedulerDidTick(_ scheduler: TimerScheduler) {
|
||||||
|
handleTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimerEngine: SmartModeCoordinatorDelegate {
|
||||||
|
func smartModeDidRequestPauseAll(_ coordinator: SmartModeCoordinator, reason: PauseReason) {
|
||||||
|
pauseAllTimers(reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartModeDidRequestResumeAll(_ coordinator: SmartModeCoordinator, reason: PauseReason) {
|
||||||
|
resumeAllTimers(reason: reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ final class WindowManager: WindowManaging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showSettings(settingsManager: any SettingsProviding, initialTab: Int) {
|
func showSettings(settingsManager: any SettingsProviding, initialTab: Int) {
|
||||||
// Use the existing presenter for now
|
|
||||||
if let realSettings = settingsManager as? SettingsManager {
|
if let realSettings = settingsManager as? SettingsManager {
|
||||||
SettingsWindowPresenter.shared.show(settingsManager: realSettings, initialTab: initialTab)
|
SettingsWindowPresenter.shared.show(settingsManager: realSettings, initialTab: initialTab)
|
||||||
}
|
}
|
||||||
|
|||||||
74
Gaze/Views/Components/ExternalLinkButton.swift
Normal file
74
Gaze/Views/Components/ExternalLinkButton.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//
|
||||||
|
// ExternalLinkButton.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Reusable external link button component.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ExternalLinkButton: View {
|
||||||
|
let icon: String
|
||||||
|
var iconColor: Color = .primary
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let url: String
|
||||||
|
let tint: Color?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: openURL) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(iconColor)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.glassEffectIfAvailable(
|
||||||
|
tint != nil ? GlassStyle.regular.tint(tint!).interactive() : GlassStyle.regular.interactive(),
|
||||||
|
in: .rect(cornerRadius: 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openURL() {
|
||||||
|
if let url = URL(string: url) {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack {
|
||||||
|
ExternalLinkButton(
|
||||||
|
icon: "star.fill",
|
||||||
|
iconColor: .yellow,
|
||||||
|
title: "Example Link",
|
||||||
|
subtitle: "A subtitle describing the link",
|
||||||
|
url: "https://example.com",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
|
||||||
|
ExternalLinkButton(
|
||||||
|
icon: "link",
|
||||||
|
title: "Plain Link",
|
||||||
|
subtitle: "Without tint",
|
||||||
|
url: "https://example.com",
|
||||||
|
tint: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
111
Gaze/Views/Containers/SettingsWindowPresenter.swift
Normal file
111
Gaze/Views/Containers/SettingsWindowPresenter.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// SettingsWindowPresenter.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/8/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SettingsWindowPresenter {
|
||||||
|
static let shared = SettingsWindowPresenter()
|
||||||
|
|
||||||
|
static let switchTabNotification = Notification.Name("SwitchToSettingsTab")
|
||||||
|
|
||||||
|
private var windowController: NSWindowController?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func show(settingsManager: SettingsManager, initialTab: Int = 0) {
|
||||||
|
if focusExistingWindow(tab: initialTab) { return }
|
||||||
|
createWindow(settingsManager: settingsManager, initialTab: initialTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(settingsManager: SettingsManager, section: SettingsSection) {
|
||||||
|
show(settingsManager: settingsManager, initialTab: section.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
windowController?.close()
|
||||||
|
windowController = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var isVisible: Bool {
|
||||||
|
windowController?.window?.isVisible ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func focusExistingWindow(tab: Int?) -> Bool {
|
||||||
|
guard let window = windowController?.window else {
|
||||||
|
windowController = nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let tab {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: Self.switchTabNotification,
|
||||||
|
object: tab
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NSApp.unhide(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
if window.isMiniaturized {
|
||||||
|
window.deminiaturize(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindow(settingsManager: SettingsManager, initialTab: Int) {
|
||||||
|
let window = makeWindow()
|
||||||
|
|
||||||
|
window.contentView = NSHostingView(
|
||||||
|
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
|
||||||
|
)
|
||||||
|
|
||||||
|
let controller = NSWindowController(window: window)
|
||||||
|
controller.showWindow(nil)
|
||||||
|
NSApp.unhide(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
|
||||||
|
windowController = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeWindow() -> NSWindow {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(
|
||||||
|
x: 0, y: 0,
|
||||||
|
width: AdaptiveLayout.Window.defaultWidth,
|
||||||
|
height: AdaptiveLayout.Window.defaultHeight
|
||||||
|
),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
window.identifier = WindowIdentifiers.settings
|
||||||
|
window.titleVisibility = .hidden
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.toolbarStyle = .unified
|
||||||
|
window.showsToolbarButton = false
|
||||||
|
window.center()
|
||||||
|
window.setFrameAutosaveName("SettingsWindow")
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
window.collectionBehavior = [
|
||||||
|
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
|
||||||
|
]
|
||||||
|
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,93 +7,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class SettingsWindowPresenter {
|
|
||||||
static let shared = SettingsWindowPresenter()
|
|
||||||
|
|
||||||
private var windowController: NSWindowController?
|
|
||||||
private var closeObserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
func show(settingsManager: SettingsManager, initialTab: Int = 0) {
|
|
||||||
if focusExistingWindow(tab: initialTab) { return }
|
|
||||||
|
|
||||||
createWindow(settingsManager: settingsManager, initialTab: initialTab)
|
|
||||||
}
|
|
||||||
|
|
||||||
func close() {
|
|
||||||
windowController?.close()
|
|
||||||
windowController = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func focusExistingWindow(tab: Int?) -> Bool {
|
|
||||||
guard let window = windowController?.window else {
|
|
||||||
windowController = nil
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let tab {
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: Notification.Name("SwitchToSettingsTab"),
|
|
||||||
object: tab
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NSApp.unhide(nil)
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
|
|
||||||
if window.isMiniaturized {
|
|
||||||
window.deminiaturize(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
window.orderFrontRegardless()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createWindow(settingsManager: SettingsManager, initialTab: Int) {
|
|
||||||
let window = NSWindow(
|
|
||||||
contentRect: NSRect(
|
|
||||||
x: 0, y: 0,
|
|
||||||
width: AdaptiveLayout.Window.defaultWidth,
|
|
||||||
height: AdaptiveLayout.Window.defaultHeight
|
|
||||||
),
|
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
|
||||||
backing: .buffered,
|
|
||||||
defer: false
|
|
||||||
)
|
|
||||||
|
|
||||||
window.identifier = WindowIdentifiers.settings
|
|
||||||
window.titleVisibility = .hidden
|
|
||||||
window.titlebarAppearsTransparent = true
|
|
||||||
window.toolbarStyle = .unified
|
|
||||||
window.showsToolbarButton = false
|
|
||||||
window.center()
|
|
||||||
window.setFrameAutosaveName("SettingsWindow")
|
|
||||||
window.isReleasedWhenClosed = false
|
|
||||||
|
|
||||||
window.collectionBehavior = [
|
|
||||||
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
|
|
||||||
]
|
|
||||||
|
|
||||||
window.contentView = NSHostingView(
|
|
||||||
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
|
|
||||||
)
|
|
||||||
|
|
||||||
let controller = NSWindowController(window: window)
|
|
||||||
controller.showWindow(nil)
|
|
||||||
NSApp.unhide(nil)
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
window.orderFrontRegardless()
|
|
||||||
|
|
||||||
windowController = controller
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SettingsWindowView: View {
|
struct SettingsWindowView: View {
|
||||||
@Bindable var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@State private var selectedSection: SettingsSection
|
@State private var selectedSection: SettingsSection
|
||||||
@@ -106,52 +19,47 @@ struct SettingsWindowView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let isCompact = geometry.size.height < 600
|
let isCompact = geometry.size.height < 600
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
NavigationSplitView {
|
settingsContent
|
||||||
List(SettingsSection.allCases, selection: $selectedSection) { section in
|
|
||||||
NavigationLink(value: section) {
|
|
||||||
Label(section.title, systemImage: section.iconName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.sidebar)
|
|
||||||
} detail: {
|
|
||||||
ScrollView {
|
|
||||||
detailView(for: selectedSection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onReceive(
|
|
||||||
NotificationCenter.default.publisher(
|
|
||||||
for: Notification.Name("SwitchToSettingsTab"))
|
|
||||||
) { notification in
|
|
||||||
if let tab = notification.object as? Int,
|
|
||||||
let section = SettingsSection(rawValue: tab)
|
|
||||||
{
|
|
||||||
selectedSection = section
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Divider()
|
debugFooter(isCompact: isCompact)
|
||||||
HStack {
|
|
||||||
Button("Retrigger Onboarding") {
|
|
||||||
retriggerOnboarding()
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(isCompact ? .small : .regular)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(isCompact ? 8 : 16)
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(\.isCompactLayout, isCompact)
|
.environment(\.isCompactLayout, isCompact)
|
||||||
}
|
}
|
||||||
.frame(minWidth: AdaptiveLayout.Window.minWidth, minHeight: AdaptiveLayout.Window.minHeight)
|
.frame(minWidth: AdaptiveLayout.Window.minWidth, minHeight: AdaptiveLayout.Window.minHeight)
|
||||||
|
.onReceive(tabSwitchPublisher) { notification in
|
||||||
|
if let tab = notification.object as? Int,
|
||||||
|
let section = SettingsSection(rawValue: tab) {
|
||||||
|
selectedSection = section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var settingsContent: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
sidebarContent
|
||||||
|
} detail: {
|
||||||
|
ScrollView {
|
||||||
|
detailView(for: selectedSection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarContent: some View {
|
||||||
|
List(SettingsSection.allCases, selection: $selectedSection) { section in
|
||||||
|
NavigationLink(value: section) {
|
||||||
|
Label(section.title, systemImage: section.iconName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -179,14 +87,34 @@ struct SettingsWindowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tabSwitchPublisher: NotificationCenter.Publisher {
|
||||||
|
NotificationCenter.default.publisher(
|
||||||
|
for: SettingsWindowPresenter.switchTabNotification
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private func retriggerOnboarding() {
|
@ViewBuilder
|
||||||
SettingsWindowPresenter.shared.close()
|
private func debugFooter(isCompact: Bool) -> some View {
|
||||||
settingsManager.settings.hasCompletedOnboarding = false
|
Divider()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
HStack {
|
||||||
OnboardingWindowPresenter.shared.show(settingsManager: settingsManager)
|
Button("Retrigger Onboarding") {
|
||||||
|
retriggerOnboarding()
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(isCompact ? .small : .regular)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding(isCompact ? 8 : 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retriggerOnboarding() {
|
||||||
|
SettingsWindowPresenter.shared.close()
|
||||||
|
settingsManager.settings.hasCompletedOnboarding = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
OnboardingWindowPresenter.shared.show(settingsManager: settingsManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import SwiftUI
|
|||||||
|
|
||||||
struct GeneralSetupView: View {
|
struct GeneralSetupView: View {
|
||||||
@Bindable var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
var updateManager = UpdateManager.shared
|
|
||||||
var isOnboarding: Bool = true
|
var isOnboarding: Bool = true
|
||||||
|
|
||||||
|
#if !APPSTORE
|
||||||
|
var updateManager = UpdateManager.shared
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SetupHeader(
|
SetupHeader(
|
||||||
icon: "gearshape.fill", title: isOnboarding ? "Final Settings" : "General Settings",
|
icon: "gearshape.fill",
|
||||||
color: .accentColor)
|
title: isOnboarding ? "Final Settings" : "General Settings",
|
||||||
|
color: .accentColor
|
||||||
|
)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
@@ -25,19 +30,7 @@ struct GeneralSetupView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
VStack(spacing: 20) {
|
settingsContent
|
||||||
launchAtLoginToggle
|
|
||||||
|
|
||||||
#if !APPSTORE
|
|
||||||
softwareUpdatesSection
|
|
||||||
#endif
|
|
||||||
|
|
||||||
subtleReminderSizeSection
|
|
||||||
|
|
||||||
#if !APPSTORE
|
|
||||||
supportSection
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -46,201 +39,21 @@ struct GeneralSetupView: View {
|
|||||||
.background(.clear)
|
.background(.clear)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var launchAtLoginToggle: some View {
|
@ViewBuilder
|
||||||
HStack {
|
private var settingsContent: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(spacing: 20) {
|
||||||
Text("Launch at Login")
|
LaunchAtLoginSection(isEnabled: $settingsManager.settings.launchAtLogin)
|
||||||
.font(.headline)
|
|
||||||
Text("Start Gaze automatically when you log in")
|
#if !APPSTORE
|
||||||
.font(.caption)
|
SoftwareUpdatesSection(updateManager: updateManager)
|
||||||
.foregroundStyle(.secondary)
|
#endif
|
||||||
}
|
|
||||||
Spacer()
|
ReminderSizeSection(selectedSize: $settingsManager.settings.subtleReminderSize)
|
||||||
Toggle("", isOn: $settingsManager.settings.launchAtLogin)
|
|
||||||
.labelsHidden()
|
#if !APPSTORE
|
||||||
.onChange(of: settingsManager.settings.launchAtLogin) { _, isEnabled in
|
SupportSection()
|
||||||
applyLaunchAtLoginSetting(enabled: isEnabled)
|
#endif
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !APPSTORE
|
|
||||||
private var softwareUpdatesSection: some View {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Software Updates")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
if let lastCheck = updateManager.lastUpdateCheckDate {
|
|
||||||
Text("Last checked: \(lastCheck, style: .relative)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.italic()
|
|
||||||
} else {
|
|
||||||
Text("Never checked for updates")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.italic()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button("Check for Updates Now") {
|
|
||||||
updateManager.checkForUpdates()
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
|
|
||||||
Toggle(
|
|
||||||
"Automatically check for updates",
|
|
||||||
isOn: Binding(
|
|
||||||
get: { updateManager.automaticallyChecksForUpdates },
|
|
||||||
set: { updateManager.automaticallyChecksForUpdates = $0 }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.labelsHidden()
|
|
||||||
.help("Check for new versions of Gaze in the background")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private var subtleReminderSizeSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Subtle Reminder Size")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Text("Adjust the size of blink and posture reminders")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
ForEach(ReminderSize.allCases, id: \.self) { size in
|
|
||||||
Button(action: { settingsManager.settings.subtleReminderSize = size }) {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Circle()
|
|
||||||
.fill(
|
|
||||||
settingsManager.settings.subtleReminderSize == size
|
|
||||||
? Color.accentColor : Color.secondary.opacity(0.3)
|
|
||||||
)
|
|
||||||
.frame(width: iconSize(for: size), height: iconSize(for: size))
|
|
||||||
|
|
||||||
Text(size.displayName)
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(
|
|
||||||
settingsManager.settings.subtleReminderSize == size
|
|
||||||
? .semibold : .regular
|
|
||||||
)
|
|
||||||
.foregroundStyle(
|
|
||||||
settingsManager.settings.subtleReminderSize == size
|
|
||||||
? .primary : .secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 60)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
.glassEffectIfAvailable(
|
|
||||||
settingsManager.settings.subtleReminderSize == size
|
|
||||||
? GlassStyle.regular.tint(.accentColor.opacity(0.3))
|
|
||||||
: GlassStyle.regular,
|
|
||||||
in: .rect(cornerRadius: 10)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !APPSTORE
|
|
||||||
private var supportSection: some View {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Text("Support & Contribute")
|
|
||||||
.font(.headline)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
ExternalLinkButton(
|
|
||||||
icon: "chevron.left.forwardslash.chevron.right",
|
|
||||||
title: "View on GitHub",
|
|
||||||
subtitle: "Star the repo, report issues, contribute",
|
|
||||||
url: "https://github.com/mikefreno/Gaze",
|
|
||||||
tint: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
ExternalLinkButton(
|
|
||||||
icon: "cup.and.saucer.fill",
|
|
||||||
iconColor: .brown,
|
|
||||||
title: "Buy Me a Coffee",
|
|
||||||
subtitle: "Support development of Gaze",
|
|
||||||
url: "https://buymeacoffee.com/mikefreno",
|
|
||||||
tint: .orange
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private func applyLaunchAtLoginSetting(enabled: Bool) {
|
|
||||||
do {
|
|
||||||
if enabled {
|
|
||||||
try LaunchAtLoginManager.enable()
|
|
||||||
} else {
|
|
||||||
try LaunchAtLoginManager.disable()
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func iconSize(for size: ReminderSize) -> CGFloat {
|
|
||||||
switch size {
|
|
||||||
case .small: return 20
|
|
||||||
case .medium: return 32
|
|
||||||
case .large: return 48
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExternalLinkButton: View {
|
|
||||||
let icon: String
|
|
||||||
var iconColor: Color = .primary
|
|
||||||
let title: String
|
|
||||||
let subtitle: String
|
|
||||||
let url: String
|
|
||||||
let tint: Color?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: {
|
|
||||||
if let url = URL(string: url) {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(iconColor)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(title)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
Text(subtitle)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.contentShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.glassEffectIfAvailable(
|
|
||||||
tint != nil
|
|
||||||
? GlassStyle.regular.tint(tint!).interactive() : GlassStyle.regular.interactive(),
|
|
||||||
in: .rect(cornerRadius: 10)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
49
Gaze/Views/Setup/Sections/LaunchAtLoginSection.swift
Normal file
49
Gaze/Views/Setup/Sections/LaunchAtLoginSection.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// LaunchAtLoginSection.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Launch at login toggle section.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LaunchAtLoginSection: View {
|
||||||
|
@Binding var isEnabled: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Launch at Login")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Start Gaze automatically when you log in")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: $isEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.onChange(of: isEnabled) { _, newValue in
|
||||||
|
applyLaunchAtLoginSetting(enabled: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyLaunchAtLoginSetting(enabled: Bool) {
|
||||||
|
do {
|
||||||
|
if enabled {
|
||||||
|
try LaunchAtLoginManager.enable()
|
||||||
|
} else {
|
||||||
|
try LaunchAtLoginManager.disable()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logError("⚠️ Failed to set launch at login: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LaunchAtLoginSection(isEnabled: .constant(true))
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
81
Gaze/Views/Setup/Sections/ReminderSizeSection.swift
Normal file
81
Gaze/Views/Setup/Sections/ReminderSizeSection.swift
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// ReminderSizeSection.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Subtle reminder size picker section.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReminderSizeSection: View {
|
||||||
|
@Binding var selectedSize: ReminderSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Subtle Reminder Size")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("Adjust the size of blink and posture reminders")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
sizeButtonRow
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sizeButtonRow: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(ReminderSize.allCases, id: \.self) { size in
|
||||||
|
ReminderSizeButton(
|
||||||
|
size: size,
|
||||||
|
isSelected: selectedSize == size,
|
||||||
|
action: { selectedSize = size }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReminderSizeButton: View {
|
||||||
|
let size: ReminderSize
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3))
|
||||||
|
.frame(width: iconSize, height: iconSize)
|
||||||
|
|
||||||
|
Text(size.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(isSelected ? .semibold : .regular)
|
||||||
|
.foregroundStyle(isSelected ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 60)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.glassEffectIfAvailable(
|
||||||
|
isSelected
|
||||||
|
? GlassStyle.regular.tint(.accentColor.opacity(0.3))
|
||||||
|
: GlassStyle.regular,
|
||||||
|
in: .rect(cornerRadius: 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconSize: CGFloat {
|
||||||
|
switch size {
|
||||||
|
case .small: return 20
|
||||||
|
case .medium: return 32
|
||||||
|
case .large: return 48
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ReminderSizeSection(selectedSize: .constant(.medium))
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
64
Gaze/Views/Setup/Sections/SoftwareUpdatesSection.swift
Normal file
64
Gaze/Views/Setup/Sections/SoftwareUpdatesSection.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// SoftwareUpdatesSection.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Software updates settings section.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if !APPSTORE
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SoftwareUpdatesSection: View {
|
||||||
|
@ObservedObject var updateManager: UpdateManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Software Updates")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
lastCheckText
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Check for Updates Now") {
|
||||||
|
updateManager.checkForUpdates()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Toggle(
|
||||||
|
"Automatically check for updates",
|
||||||
|
isOn: Binding(
|
||||||
|
get: { updateManager.automaticallyChecksForUpdates },
|
||||||
|
set: { updateManager.automaticallyChecksForUpdates = $0 }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.labelsHidden()
|
||||||
|
.help("Check for new versions of Gaze in the background")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var lastCheckText: some View {
|
||||||
|
if let lastCheck = updateManager.lastUpdateCheckDate {
|
||||||
|
Text("Last checked: \(lastCheck, style: .relative)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.italic()
|
||||||
|
} else {
|
||||||
|
Text("Never checked for updates")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SoftwareUpdatesSection(updateManager: UpdateManager.shared)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
43
Gaze/Views/Setup/Sections/SupportSection.swift
Normal file
43
Gaze/Views/Setup/Sections/SupportSection.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// SupportSection.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Support and contribute links section.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if !APPSTORE
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SupportSection: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Support & Contribute")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
ExternalLinkButton(
|
||||||
|
icon: "chevron.left.forwardslash.chevron.right",
|
||||||
|
title: "View on GitHub",
|
||||||
|
subtitle: "Star the repo, report issues, contribute",
|
||||||
|
url: "https://github.com/mikefreno/Gaze",
|
||||||
|
tint: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
ExternalLinkButton(
|
||||||
|
icon: "cup.and.saucer.fill",
|
||||||
|
iconColor: .brown,
|
||||||
|
title: "Buy Me a Coffee",
|
||||||
|
subtitle: "Support development of Gaze",
|
||||||
|
url: "https://buymeacoffee.com/mikefreno",
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SupportSection()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
33
GazeTests/Helpers/MockTimeProvider.swift
Normal file
33
GazeTests/Helpers/MockTimeProvider.swift
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// MockTimeProvider.swift
|
||||||
|
// GazeTests
|
||||||
|
//
|
||||||
|
// Mock time provider for deterministic timer testing.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import Gaze
|
||||||
|
|
||||||
|
/// A mock time provider for deterministic testing.
|
||||||
|
/// Allows manual control over time in tests.
|
||||||
|
final class MockTimeProvider: TimeProviding, @unchecked Sendable {
|
||||||
|
private var currentTime: Date
|
||||||
|
|
||||||
|
init(startTime: Date = Date()) {
|
||||||
|
self.currentTime = startTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func now() -> Date {
|
||||||
|
currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances time by the specified interval
|
||||||
|
func advance(by interval: TimeInterval) {
|
||||||
|
currentTime = currentTime.addingTimeInterval(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the current time to a specific date
|
||||||
|
func setTime(_ date: Date) {
|
||||||
|
currentTime = date
|
||||||
|
}
|
||||||
|
}
|
||||||
65
GazeTests/Helpers/TestServiceContainer.swift
Normal file
65
GazeTests/Helpers/TestServiceContainer.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// TestServiceContainer.swift
|
||||||
|
// GazeTests
|
||||||
|
//
|
||||||
|
// Test-specific dependency injection container.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import Gaze
|
||||||
|
|
||||||
|
/// A dependency injection container configured for testing.
|
||||||
|
/// Provides injectable dependencies and test-specific utilities.
|
||||||
|
@MainActor
|
||||||
|
final class TestServiceContainer {
|
||||||
|
/// The settings manager instance
|
||||||
|
private(set) var settingsManager: any SettingsProviding
|
||||||
|
|
||||||
|
/// The timer engine instance
|
||||||
|
private var _timerEngine: TimerEngine?
|
||||||
|
|
||||||
|
/// Time provider for deterministic testing
|
||||||
|
let timeProvider: TimeProviding
|
||||||
|
|
||||||
|
/// Creates a test container with default mock settings
|
||||||
|
convenience init() {
|
||||||
|
self.init(settings: AppSettings())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a test container with custom settings
|
||||||
|
init(settings: AppSettings) {
|
||||||
|
self.settingsManager = EnhancedMockSettingsManager(settings: settings)
|
||||||
|
self.timeProvider = MockTimeProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a test container with a custom settings manager
|
||||||
|
init(settingsManager: any SettingsProviding) {
|
||||||
|
self.settingsManager = settingsManager
|
||||||
|
self.timeProvider = MockTimeProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets or creates the timer engine for testing
|
||||||
|
var timerEngine: TimerEngine {
|
||||||
|
if let engine = _timerEngine {
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
let engine = TimerEngine(
|
||||||
|
settingsManager: settingsManager,
|
||||||
|
enforceModeService: nil,
|
||||||
|
timeProvider: timeProvider
|
||||||
|
)
|
||||||
|
_timerEngine = engine
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a custom timer engine
|
||||||
|
func setTimerEngine(_ engine: TimerEngine) {
|
||||||
|
_timerEngine = engine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the container for test isolation
|
||||||
|
func reset() {
|
||||||
|
_timerEngine?.stop()
|
||||||
|
_timerEngine = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,31 +14,30 @@ final class ServiceContainerTests: XCTestCase {
|
|||||||
func testProductionContainerCreation() {
|
func testProductionContainerCreation() {
|
||||||
let container = ServiceContainer.shared
|
let container = ServiceContainer.shared
|
||||||
|
|
||||||
XCTAssertFalse(container.isTestEnvironment)
|
|
||||||
XCTAssertNotNil(container.settingsManager)
|
XCTAssertNotNil(container.settingsManager)
|
||||||
XCTAssertNotNil(container.enforceModeService)
|
XCTAssertNotNil(container.enforceModeService)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTestContainerCreation() {
|
func testTestContainerCreation() {
|
||||||
let settings = AppSettings.onlyLookAwayEnabled
|
let settings = AppSettings.onlyLookAwayEnabled
|
||||||
let container = ServiceContainer.forTesting(settings: settings)
|
let container = TestServiceContainer(settings: settings)
|
||||||
|
|
||||||
XCTAssertTrue(container.isTestEnvironment)
|
|
||||||
XCTAssertEqual(container.settingsManager.settings.lookAwayTimer.enabled, true)
|
XCTAssertEqual(container.settingsManager.settings.lookAwayTimer.enabled, true)
|
||||||
XCTAssertEqual(container.settingsManager.settings.blinkTimer.enabled, false)
|
XCTAssertEqual(container.settingsManager.settings.blinkTimer.enabled, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTimerEngineCreation() {
|
func testTimerEngineCreation() {
|
||||||
let container = ServiceContainer.forTesting()
|
let container = TestServiceContainer()
|
||||||
let timerEngine = container.timerEngine
|
let timerEngine = container.timerEngine
|
||||||
|
|
||||||
XCTAssertNotNil(timerEngine)
|
XCTAssertNotNil(timerEngine)
|
||||||
// Second access should return the same instance
|
// Second access should return the same instance
|
||||||
XCTAssertTrue(container.timerEngine === timerEngine)
|
XCTAssertTrue(container.timerEngine === timerEngine)
|
||||||
|
XCTAssertTrue(container.timeProvider is MockTimeProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCustomTimerEngineInjection() {
|
func testCustomTimerEngineInjection() {
|
||||||
let container = ServiceContainer.forTesting()
|
let container = TestServiceContainer()
|
||||||
let mockSettings = EnhancedMockSettingsManager(settings: .shortIntervals)
|
let mockSettings = EnhancedMockSettingsManager(settings: .shortIntervals)
|
||||||
let customEngine = TimerEngine(
|
let customEngine = TimerEngine(
|
||||||
settingsManager: mockSettings,
|
settingsManager: mockSettings,
|
||||||
@@ -51,10 +50,10 @@ final class ServiceContainerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testContainerReset() {
|
func testContainerReset() {
|
||||||
let container = ServiceContainer.forTesting()
|
let container = TestServiceContainer()
|
||||||
|
|
||||||
// Access timer engine to create it
|
// Access timer engine to create it
|
||||||
_ = container.timerEngine
|
let existingEngine = container.timerEngine
|
||||||
|
|
||||||
// Reset should clear the timer engine
|
// Reset should clear the timer engine
|
||||||
container.reset()
|
container.reset()
|
||||||
@@ -62,5 +61,6 @@ final class ServiceContainerTests: XCTestCase {
|
|||||||
// Accessing again should create a new instance
|
// Accessing again should create a new instance
|
||||||
let newEngine = container.timerEngine
|
let newEngine = container.timerEngine
|
||||||
XCTAssertNotNil(newEngine)
|
XCTAssertNotNil(newEngine)
|
||||||
|
XCTAssertFalse(existingEngine === newEngine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,28 +202,28 @@ extension AppSettings {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func createTestContainer(
|
func createTestContainer(
|
||||||
settings: AppSettings = .defaults
|
settings: AppSettings = .defaults
|
||||||
) -> ServiceContainer {
|
) -> TestServiceContainer {
|
||||||
return ServiceContainer.forTesting(settings: settings)
|
return TestServiceContainer(settings: settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a complete test environment with all mocks
|
/// Creates a complete test environment with all mocks
|
||||||
@MainActor
|
@MainActor
|
||||||
struct TestEnvironment {
|
struct TestEnvironment {
|
||||||
let container: ServiceContainer
|
let container: TestServiceContainer
|
||||||
let windowManager: MockWindowManager
|
let windowManager: MockWindowManager
|
||||||
let settingsManager: EnhancedMockSettingsManager
|
let settingsManager: EnhancedMockSettingsManager
|
||||||
let timeProvider: MockTimeProvider
|
let timeProvider: MockTimeProvider
|
||||||
|
|
||||||
init(settings: AppSettings = .defaults) {
|
init(settings: AppSettings = .defaults) {
|
||||||
self.settingsManager = EnhancedMockSettingsManager(settings: settings)
|
self.settingsManager = EnhancedMockSettingsManager(settings: settings)
|
||||||
self.container = ServiceContainer(settingsManager: settingsManager)
|
self.container = TestServiceContainer(settingsManager: settingsManager)
|
||||||
self.windowManager = MockWindowManager()
|
self.windowManager = MockWindowManager()
|
||||||
self.timeProvider = MockTimeProvider()
|
self.timeProvider = MockTimeProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates an AppDelegate with all test dependencies
|
/// Creates an AppDelegate with all test dependencies
|
||||||
func createAppDelegate() -> AppDelegate {
|
func createAppDelegate() -> AppDelegate {
|
||||||
return AppDelegate(serviceContainer: container, windowManager: windowManager)
|
return AppDelegate(serviceContainer: serviceContainer, windowManager: windowManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets all mock state
|
/// Resets all mock state
|
||||||
@@ -231,6 +231,13 @@ struct TestEnvironment {
|
|||||||
windowManager.reset()
|
windowManager.reset()
|
||||||
settingsManager.reset()
|
settingsManager.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var serviceContainer: ServiceContainer {
|
||||||
|
ServiceContainer(
|
||||||
|
settingsManager: settingsManager,
|
||||||
|
enforceModeService: EnforceModeService.shared
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - XCTest Extensions
|
// MARK: - XCTest Extensions
|
||||||
|
|||||||
Reference in New Issue
Block a user