pass 1
This commit is contained in:
@@ -19,26 +19,3 @@ struct SystemTimeProvider: TimeProviding {
|
||||
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
|
||||
|
||||
private var settingsManager: SettingsManager
|
||||
private var eyeTrackingService: EyeTrackingService
|
||||
private let policyEvaluator: EnforcePolicyEvaluator
|
||||
private let cameraController: EnforceCameraController
|
||||
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() {
|
||||
self.settingsManager = SettingsManager.shared
|
||||
self.eyeTrackingService = EyeTrackingService.shared
|
||||
setupObservers()
|
||||
self.policyEvaluator = EnforcePolicyEvaluator(settingsProvider: SettingsManager.shared)
|
||||
self.cameraController = EnforceCameraController(eyeTrackingService: EyeTrackingService.shared)
|
||||
self.cameraController.delegate = self
|
||||
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() {
|
||||
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 settingsEnabled && cameraService.isCameraAuthorized {
|
||||
@@ -104,27 +86,17 @@ class EnforceModeService: ObservableObject {
|
||||
|
||||
func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool {
|
||||
guard isEnforceModeEnabled else { return false }
|
||||
guard settingsManager.settings.enforcementMode else { return false }
|
||||
|
||||
switch timerIdentifier {
|
||||
case .builtIn(let type):
|
||||
return type == .lookAway
|
||||
case .user:
|
||||
return false
|
||||
}
|
||||
return policyEvaluator.shouldEnforce(timerIdentifier: timerIdentifier)
|
||||
}
|
||||
|
||||
func startCameraForLookawayTimer(secondsRemaining: Int) async {
|
||||
guard isEnforceModeEnabled else { return }
|
||||
guard !isCameraActive else { return }
|
||||
|
||||
logDebug("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
|
||||
|
||||
do {
|
||||
try await eyeTrackingService.startEyeTracking()
|
||||
isCameraActive = true
|
||||
lastFaceDetectionTime = Date() // Reset grace period
|
||||
startFaceDetectionTimer()
|
||||
try await cameraController.startCamera()
|
||||
isCameraActive = cameraController.isCameraActive
|
||||
logDebug("✓ Camera active")
|
||||
} catch {
|
||||
logError("⚠️ Failed to start camera: \(error.localizedDescription)")
|
||||
@@ -135,11 +107,9 @@ class EnforceModeService: ObservableObject {
|
||||
guard isCameraActive else { return }
|
||||
|
||||
logDebug("👁️ Stopping camera")
|
||||
eyeTrackingService.stopEyeTracking()
|
||||
cameraController.stopCamera()
|
||||
isCameraActive = false
|
||||
userCompliedWithBreak = false
|
||||
|
||||
stopFaceDetectionTimer()
|
||||
}
|
||||
|
||||
func checkUserCompliance() {
|
||||
@@ -147,54 +117,18 @@ class EnforceModeService: ObservableObject {
|
||||
userCompliedWithBreak = false
|
||||
return
|
||||
}
|
||||
let compliance = policyEvaluator.evaluateCompliance(
|
||||
isLookingAtScreen: EyeTrackingService.shared.userLookingAtScreen,
|
||||
faceDetected: EyeTrackingService.shared.faceDetected
|
||||
)
|
||||
|
||||
let lookingAway = !eyeTrackingService.userLookingAtScreen
|
||||
userCompliedWithBreak = lookingAway
|
||||
}
|
||||
|
||||
private func handleGazeChange(lookingAtScreen: Bool) {
|
||||
guard isCameraActive else { return }
|
||||
|
||||
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()
|
||||
switch compliance {
|
||||
case .compliant:
|
||||
userCompliedWithBreak = true
|
||||
case .notCompliant:
|
||||
userCompliedWithBreak = false
|
||||
case .faceNotDetected:
|
||||
userCompliedWithBreak = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,16 +142,13 @@ class EnforceModeService: ObservableObject {
|
||||
|
||||
func startTestMode() async {
|
||||
guard isEnforceModeEnabled else { return }
|
||||
guard !isCameraActive else { return }
|
||||
|
||||
logDebug("🧪 Starting test mode")
|
||||
isTestMode = true
|
||||
|
||||
do {
|
||||
try await eyeTrackingService.startEyeTracking()
|
||||
isCameraActive = true
|
||||
lastFaceDetectionTime = Date() // Reset grace period
|
||||
startFaceDetectionTimer()
|
||||
try await cameraController.startCamera()
|
||||
isCameraActive = cameraController.isCameraActive
|
||||
logDebug("✓ Test mode camera active")
|
||||
} catch {
|
||||
logError("⚠️ Failed to start test mode camera: \(error.localizedDescription)")
|
||||
@@ -233,3 +164,17 @@ class EnforceModeService: ObservableObject {
|
||||
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.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A simple dependency injection container for managing service instances.
|
||||
/// Supports both production and test configurations.
|
||||
@MainActor
|
||||
final class ServiceContainer {
|
||||
|
||||
@@ -34,27 +32,22 @@ final class ServiceContainer {
|
||||
/// The usage tracking service
|
||||
private(set) var usageTrackingService: UsageTrackingService?
|
||||
|
||||
/// Whether this container is configured for testing
|
||||
let isTestEnvironment: Bool
|
||||
|
||||
/// Creates a production container with real services
|
||||
private init() {
|
||||
self.isTestEnvironment = false
|
||||
self.settingsManager = SettingsManager.shared
|
||||
self.enforceModeService = EnforceModeService.shared
|
||||
}
|
||||
|
||||
/// Creates a test container with injectable dependencies
|
||||
|
||||
/// Creates a container with injectable dependencies
|
||||
/// - Parameters:
|
||||
/// - settingsManager: The settings manager to use
|
||||
/// - enforceModeService: The enforce mode service to use
|
||||
init(
|
||||
settingsManager: any SettingsProviding,
|
||||
enforceModeService: EnforceModeService? = nil
|
||||
enforceModeService: EnforceModeService
|
||||
) {
|
||||
self.isTestEnvironment = true
|
||||
self.settingsManager = settingsManager
|
||||
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
|
||||
self.enforceModeService = enforceModeService
|
||||
}
|
||||
|
||||
/// Gets or creates the timer engine
|
||||
@@ -65,17 +58,12 @@ final class ServiceContainer {
|
||||
let engine = TimerEngine(
|
||||
settingsManager: settingsManager,
|
||||
enforceModeService: enforceModeService,
|
||||
timeProvider: isTestEnvironment ? MockTimeProvider() : SystemTimeProvider()
|
||||
timeProvider: SystemTimeProvider()
|
||||
)
|
||||
_timerEngine = engine
|
||||
return engine
|
||||
}
|
||||
|
||||
/// Sets a custom timer engine (useful for testing)
|
||||
func setTimerEngine(_ engine: TimerEngine) {
|
||||
_timerEngine = engine
|
||||
}
|
||||
|
||||
/// Sets up smart mode services
|
||||
func setupSmartModeServices() {
|
||||
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 activeReminder: ReminderEvent?
|
||||
|
||||
private var timerSubscription: AnyCancellable?
|
||||
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
|
||||
|
||||
// For enforce mode integration
|
||||
private var enforceModeService: EnforceModeService?
|
||||
|
||||
// Smart Mode services
|
||||
private var fullscreenService: FullscreenDetectionService?
|
||||
private var idleService: IdleMonitoringService?
|
||||
private let stateManager = TimerStateManager()
|
||||
private let scheduler: TimerScheduler
|
||||
private let reminderService: ReminderTriggerService
|
||||
private let smartModeCoordinator = SmartModeCoordinator()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
convenience init(
|
||||
@@ -50,127 +38,66 @@ class TimerEngine: ObservableObject {
|
||||
timeProvider: TimeProviding
|
||||
) {
|
||||
self.settingsProvider = settingsManager
|
||||
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
|
||||
self.timeProvider = timeProvider
|
||||
self.scheduler = TimerScheduler(timeProvider: timeProvider)
|
||||
self.reminderService = ReminderTriggerService(
|
||||
settingsProvider: settingsManager,
|
||||
enforceModeService: enforceModeService ?? EnforceModeService.shared
|
||||
)
|
||||
|
||||
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(
|
||||
fullscreenService: FullscreenDetectionService?,
|
||||
idleService: IdleMonitoringService?
|
||||
) {
|
||||
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 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")
|
||||
}
|
||||
smartModeCoordinator.setup(
|
||||
fullscreenService: fullscreenService,
|
||||
idleService: idleService,
|
||||
settingsProvider: settingsProvider
|
||||
)
|
||||
}
|
||||
|
||||
func pauseAllTimers(reason: PauseReason) {
|
||||
for (id, var state) in timerStates {
|
||||
state.pauseReasons.insert(reason)
|
||||
state.isPaused = true
|
||||
timerStates[id] = state
|
||||
}
|
||||
stateManager.pauseAll(reason: reason)
|
||||
}
|
||||
|
||||
func resumeAllTimers(reason: PauseReason) {
|
||||
for (id, var state) in timerStates {
|
||||
state.pauseReasons.remove(reason)
|
||||
state.isPaused = !state.pauseReasons.isEmpty
|
||||
timerStates[id] = state
|
||||
}
|
||||
stateManager.resumeAll(reason: reason)
|
||||
}
|
||||
|
||||
func start() {
|
||||
// If timers are already running, just update configurations without resetting
|
||||
if timerSubscription != nil {
|
||||
if scheduler.isRunning {
|
||||
updateConfigurations()
|
||||
return
|
||||
}
|
||||
|
||||
// Initial start - create all timer states
|
||||
stop()
|
||||
|
||||
var newStates: [TimerIdentifier: TimerState] = [:]
|
||||
|
||||
// Add built-in timers (using unified approach)
|
||||
for timerType in TimerType.allCases {
|
||||
let 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()
|
||||
}
|
||||
}
|
||||
stateManager.initializeTimers(
|
||||
using: timerConfigurations(),
|
||||
userTimers: settingsProvider.settings.userTimers
|
||||
)
|
||||
scheduler.start()
|
||||
}
|
||||
|
||||
/// Check if enforce mode is active and should affect timer behavior
|
||||
@@ -180,130 +107,36 @@ class TimerEngine: ObservableObject {
|
||||
|
||||
private func updateConfigurations() {
|
||||
logDebug("Updating timer configurations")
|
||||
var newStates: [TimerIdentifier: TimerState] = [:]
|
||||
|
||||
// Update built-in timers (using unified approach)
|
||||
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
|
||||
stateManager.updateConfigurations(
|
||||
using: timerConfigurations(),
|
||||
userTimers: settingsProvider.settings.userTimers
|
||||
)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timerSubscription?.cancel()
|
||||
timerSubscription = nil
|
||||
timerStates.removeAll()
|
||||
scheduler.stop()
|
||||
stateManager.clearAll()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
for (id, var state) in timerStates {
|
||||
state.pauseReasons.insert(.manual)
|
||||
state.isPaused = true
|
||||
timerStates[id] = state
|
||||
}
|
||||
stateManager.pauseAll(reason: .manual)
|
||||
}
|
||||
|
||||
func resume() {
|
||||
for (id, var state) in timerStates {
|
||||
state.pauseReasons.remove(.manual)
|
||||
state.isPaused = !state.pauseReasons.isEmpty
|
||||
timerStates[id] = state
|
||||
}
|
||||
stateManager.resumeAll(reason: .manual)
|
||||
}
|
||||
|
||||
func pauseTimer(identifier: TimerIdentifier) {
|
||||
guard var state = timerStates[identifier] else { return }
|
||||
state.pauseReasons.insert(.manual)
|
||||
state.isPaused = true
|
||||
timerStates[identifier] = state
|
||||
stateManager.pauseTimer(identifier: identifier, reason: .manual)
|
||||
}
|
||||
|
||||
func resumeTimer(identifier: TimerIdentifier) {
|
||||
guard var state = timerStates[identifier] else { return }
|
||||
state.pauseReasons.remove(.manual)
|
||||
state.isPaused = !state.pauseReasons.isEmpty
|
||||
timerStates[identifier] = state
|
||||
stateManager.resumeTimer(identifier: identifier, reason: .manual)
|
||||
}
|
||||
|
||||
func skipNext(identifier: TimerIdentifier) {
|
||||
guard let state = timerStates[identifier] else { return }
|
||||
|
||||
// Unified approach to get interval - no more separate handling for user timers
|
||||
func skipNext(identifier: TimerIdentifier) {
|
||||
let intervalSeconds = getTimerInterval(for: identifier)
|
||||
|
||||
timerStates[identifier] = TimerState(
|
||||
identifier: identifier,
|
||||
intervalSeconds: intervalSeconds,
|
||||
isPaused: state.isPaused,
|
||||
isActive: state.isActive
|
||||
)
|
||||
stateManager.resetTimer(identifier: identifier, intervalSeconds: intervalSeconds)
|
||||
}
|
||||
|
||||
/// Unified way to get interval for any timer type
|
||||
@@ -322,13 +155,13 @@ func skipNext(identifier: TimerIdentifier) {
|
||||
|
||||
func dismissReminder() {
|
||||
guard let reminder = activeReminder else { return }
|
||||
activeReminder = nil
|
||||
stateManager.setReminder(nil)
|
||||
|
||||
let identifier = reminder.identifier
|
||||
skipNext(identifier: identifier)
|
||||
resumeTimer(identifier: identifier)
|
||||
|
||||
enforceModeService?.handleReminderDismissed()
|
||||
reminderService.handleReminderDismissed()
|
||||
}
|
||||
|
||||
private func handleTick() {
|
||||
@@ -341,24 +174,22 @@ func skipNext(identifier: TimerIdentifier) {
|
||||
continue
|
||||
}
|
||||
|
||||
timerStates[identifier]?.remainingSeconds -= 1
|
||||
guard let updatedState = stateManager.decrementTimer(identifier: identifier) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if let updatedState = timerStates[identifier] {
|
||||
// Unified approach - no more special handling needed for any timer type
|
||||
if updatedState.remainingSeconds <= 3 && !updatedState.isPaused {
|
||||
// Enforce mode is handled generically, not specifically for lookAway only
|
||||
if enforceModeService?.shouldEnforceBreak(for: identifier) == true {
|
||||
Task { @MainActor in
|
||||
await enforceModeService?.startCameraForLookawayTimer(
|
||||
secondsRemaining: updatedState.remainingSeconds)
|
||||
}
|
||||
}
|
||||
if reminderService.shouldPrepareEnforceMode(
|
||||
for: identifier,
|
||||
secondsRemaining: updatedState.remainingSeconds
|
||||
) {
|
||||
Task { @MainActor in
|
||||
await reminderService.prepareEnforceMode(secondsRemaining: updatedState.remainingSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
if updatedState.remainingSeconds <= 0 {
|
||||
triggerReminder(for: identifier)
|
||||
break
|
||||
}
|
||||
if updatedState.remainingSeconds <= 0 {
|
||||
triggerReminder(for: identifier)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,28 +198,13 @@ func skipNext(identifier: TimerIdentifier) {
|
||||
// Pause only the timer that triggered
|
||||
pauseTimer(identifier: identifier)
|
||||
|
||||
// Unified approach to handle all timer types - no more special handling
|
||||
switch identifier {
|
||||
case .builtIn(let type):
|
||||
switch type {
|
||||
case .lookAway:
|
||||
activeReminder = .lookAwayTriggered(
|
||||
countdownSeconds: settingsProvider.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)
|
||||
}
|
||||
if let reminder = reminderService.reminderEvent(for: identifier) {
|
||||
stateManager.setReminder(reminder)
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
|
||||
guard let state = timerStates[identifier] else { return 0 }
|
||||
return TimeInterval(state.remainingSeconds)
|
||||
stateManager.getTimeRemaining(for: identifier)
|
||||
}
|
||||
|
||||
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {
|
||||
@@ -396,11 +212,11 @@ func skipNext(identifier: TimerIdentifier) {
|
||||
}
|
||||
|
||||
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
|
||||
return timerStates[identifier]?.isPaused ?? true
|
||||
return stateManager.isTimerPaused(identifier)
|
||||
}
|
||||
|
||||
// System sleep/wake handling is now managed by SystemSleepManager
|
||||
// This method is kept for compatibility but will be removed in future versions
|
||||
// System sleep/wake handling is now managed by SystemSleepManager
|
||||
// This method is kept for compatibility but will be removed in future versions
|
||||
/// Handles system sleep event - deprecated
|
||||
@available(*, deprecated, message: "Use SystemSleepManager instead")
|
||||
func handleSystemSleep() {
|
||||
@@ -414,5 +230,29 @@ func skipNext(identifier: TimerIdentifier) {
|
||||
logDebug("System waking up (deprecated)")
|
||||
// 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) {
|
||||
// Use the existing presenter for now
|
||||
if let realSettings = settingsManager as? SettingsManager {
|
||||
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
|
||||
|
||||
@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 {
|
||||
@Bindable var settingsManager: SettingsManager
|
||||
@State private var selectedSection: SettingsSection
|
||||
@@ -106,52 +19,47 @@ struct SettingsWindowView: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let isCompact = geometry.size.height < 600
|
||||
|
||||
|
||||
ZStack {
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
NavigationSplitView {
|
||||
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
|
||||
}
|
||||
}
|
||||
settingsContent
|
||||
|
||||
#if DEBUG
|
||||
Divider()
|
||||
HStack {
|
||||
Button("Retrigger Onboarding") {
|
||||
retriggerOnboarding()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(isCompact ? .small : .regular)
|
||||
Spacer()
|
||||
}
|
||||
.padding(isCompact ? 8 : 16)
|
||||
debugFooter(isCompact: isCompact)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.environment(\.isCompactLayout, isCompact)
|
||||
}
|
||||
.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
|
||||
@@ -179,14 +87,34 @@ struct SettingsWindowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var tabSwitchPublisher: NotificationCenter.Publisher {
|
||||
NotificationCenter.default.publisher(
|
||||
for: SettingsWindowPresenter.switchTabNotification
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func retriggerOnboarding() {
|
||||
SettingsWindowPresenter.shared.close()
|
||||
settingsManager.settings.hasCompletedOnboarding = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
OnboardingWindowPresenter.shared.show(settingsManager: settingsManager)
|
||||
@ViewBuilder
|
||||
private func debugFooter(isCompact: Bool) -> some View {
|
||||
Divider()
|
||||
HStack {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,19 @@ import SwiftUI
|
||||
|
||||
struct GeneralSetupView: View {
|
||||
@Bindable var settingsManager: SettingsManager
|
||||
var updateManager = UpdateManager.shared
|
||||
var isOnboarding: Bool = true
|
||||
|
||||
#if !APPSTORE
|
||||
var updateManager = UpdateManager.shared
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
SetupHeader(
|
||||
icon: "gearshape.fill", title: isOnboarding ? "Final Settings" : "General Settings",
|
||||
color: .accentColor)
|
||||
icon: "gearshape.fill",
|
||||
title: isOnboarding ? "Final Settings" : "General Settings",
|
||||
color: .accentColor
|
||||
)
|
||||
|
||||
Spacer()
|
||||
VStack(spacing: 30) {
|
||||
@@ -25,19 +30,7 @@ struct GeneralSetupView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
VStack(spacing: 20) {
|
||||
launchAtLoginToggle
|
||||
|
||||
#if !APPSTORE
|
||||
softwareUpdatesSection
|
||||
#endif
|
||||
|
||||
subtleReminderSizeSection
|
||||
|
||||
#if !APPSTORE
|
||||
supportSection
|
||||
#endif
|
||||
}
|
||||
settingsContent
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -46,201 +39,21 @@ struct GeneralSetupView: View {
|
||||
.background(.clear)
|
||||
}
|
||||
|
||||
private var launchAtLoginToggle: 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: $settingsManager.settings.launchAtLogin)
|
||||
.labelsHidden()
|
||||
.onChange(of: settingsManager.settings.launchAtLogin) { _, isEnabled in
|
||||
applyLaunchAtLoginSetting(enabled: isEnabled)
|
||||
}
|
||||
@ViewBuilder
|
||||
private var settingsContent: some View {
|
||||
VStack(spacing: 20) {
|
||||
LaunchAtLoginSection(isEnabled: $settingsManager.settings.launchAtLogin)
|
||||
|
||||
#if !APPSTORE
|
||||
SoftwareUpdatesSection(updateManager: updateManager)
|
||||
#endif
|
||||
|
||||
ReminderSizeSection(selectedSize: $settingsManager.settings.subtleReminderSize)
|
||||
|
||||
#if !APPSTORE
|
||||
SupportSection()
|
||||
#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
|
||||
Reference in New Issue
Block a user