diff --git a/Gaze/Data/structs.swift b/Gaze/Data/structs.swift index 405ce38..6c0387a 100644 --- a/Gaze/Data/structs.swift +++ b/Gaze/Data/structs.swift @@ -1,22 +1,18 @@ -struct Range: Codable { - var bounds: ClosedRange - var step: Int +struct Range: Codable, Equatable { + let bounds: ClosedRange + let step: Int } + struct RangeChoice: Equatable { - var val: Int? + var value: Int? let range: Range? - static func == (lhs: RangeChoice, rhs: RangeChoice) -> Bool { - lhs.val == rhs.val && lhs.range?.bounds.lowerBound == rhs.range?.bounds.lowerBound - && lhs.range?.bounds.upperBound == rhs.range?.bounds.upperBound - } - - init(val: Int? = nil, range: Range? = nil) { - self.val = val + init(value: Int? = nil, range: Range? = nil) { + self.value = value self.range = range } var isNil: Bool { - return val == nil || range == nil + value == nil || range == nil } } diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift index af63e3b..39b5d66 100644 --- a/Gaze/Models/AppSettings.swift +++ b/Gaze/Models/AppSettings.swift @@ -7,8 +7,6 @@ import Foundation -// MARK: - Reminder Size - enum ReminderSize: String, Codable, CaseIterable, Sendable { case small case medium @@ -32,16 +30,17 @@ enum ReminderSize: String, Codable, CaseIterable, Sendable { } struct AppSettings: Codable, Equatable, Hashable, Sendable { - var lookAwayTimer: TimerConfiguration - var lookAwayCountdownSeconds: Int - var blinkTimer: TimerConfiguration - var postureTimer: TimerConfiguration - var enforcementMode: Bool = false + var lookAwayEnabled: Bool + var lookAwayIntervalMinutes: Int + var blinkEnabled: Bool + var blinkIntervalMinutes: Int + var postureEnabled: Bool + var postureIntervalMinutes: Int var userTimers: [UserTimer] var subtleReminderSize: ReminderSize - + var smartMode: SmartModeSettings var hasCompletedOnboarding: Bool @@ -49,24 +48,25 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable { var playSounds: Bool init( - lookAwayTimer: TimerConfiguration = TimerConfiguration( - enabled: true, intervalSeconds: 20 * 60), - lookAwayCountdownSeconds: Int = 20, - blinkTimer: TimerConfiguration = TimerConfiguration( - enabled: false, intervalSeconds: 7 * 60), - postureTimer: TimerConfiguration = TimerConfiguration( - enabled: true, intervalSeconds: 30 * 60), + lookAwayEnabled: Bool = DefaultSettingsBuilder.lookAwayEnabled, + lookAwayIntervalMinutes: Int = DefaultSettingsBuilder.lookAwayIntervalMinutes, + blinkEnabled: Bool = DefaultSettingsBuilder.blinkEnabled, + blinkIntervalMinutes: Int = DefaultSettingsBuilder.blinkIntervalMinutes, + postureEnabled: Bool = DefaultSettingsBuilder.postureEnabled, + postureIntervalMinutes: Int = DefaultSettingsBuilder.postureIntervalMinutes, userTimers: [UserTimer] = [], - subtleReminderSize: ReminderSize = .medium, - smartMode: SmartModeSettings = .defaults, - hasCompletedOnboarding: Bool = false, - launchAtLogin: Bool = false, - playSounds: Bool = true + subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize, + smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode, + hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding, + launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin, + playSounds: Bool = DefaultSettingsBuilder.playSounds ) { - self.lookAwayTimer = lookAwayTimer - self.lookAwayCountdownSeconds = lookAwayCountdownSeconds - self.blinkTimer = blinkTimer - self.postureTimer = postureTimer + self.lookAwayEnabled = lookAwayEnabled + self.lookAwayIntervalMinutes = lookAwayIntervalMinutes + self.blinkEnabled = blinkEnabled + self.blinkIntervalMinutes = blinkIntervalMinutes + self.postureEnabled = postureEnabled + self.postureIntervalMinutes = postureIntervalMinutes self.userTimers = userTimers self.subtleReminderSize = subtleReminderSize self.smartMode = smartMode @@ -76,17 +76,6 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable { } static var defaults: AppSettings { - AppSettings( - lookAwayTimer: TimerConfiguration(enabled: true, intervalSeconds: 20 * 60), - lookAwayCountdownSeconds: 20, - blinkTimer: TimerConfiguration(enabled: false, intervalSeconds: 7 * 60), - postureTimer: TimerConfiguration(enabled: true, intervalSeconds: 30 * 60), - userTimers: [], - subtleReminderSize: .medium, - smartMode: .defaults, - hasCompletedOnboarding: false, - launchAtLogin: false, - playSounds: true - ) + DefaultSettingsBuilder.makeDefaults() } } diff --git a/Gaze/Models/CalibrationData.swift b/Gaze/Models/CalibrationData.swift index 71d850e..417559a 100644 --- a/Gaze/Models/CalibrationData.swift +++ b/Gaze/Models/CalibrationData.swift @@ -21,7 +21,7 @@ enum CalibrationStep: String, Codable, CaseIterable { case topRight case bottomLeft case bottomRight - + var displayName: String { switch self { case .center: return "Center" @@ -37,7 +37,7 @@ enum CalibrationStep: String, Codable, CaseIterable { case .bottomRight: return "Bottom Right" } } - + var instructionText: String { switch self { case .center: @@ -73,60 +73,76 @@ struct GazeSample: Codable { let leftVerticalRatio: Double? let rightVerticalRatio: Double? let averageVerticalRatio: Double - let faceWidthRatio: Double? // For distance scaling (face width / image width) + let faceWidthRatio: Double? // For distance scaling (face width / image width) let timestamp: Date - - init(leftRatio: Double?, rightRatio: Double?, leftVerticalRatio: Double? = nil, rightVerticalRatio: Double? = nil, faceWidthRatio: Double? = nil) { + + init( + leftRatio: Double?, + rightRatio: Double?, + leftVerticalRatio: Double? = nil, + rightVerticalRatio: Double? = nil, + faceWidthRatio: Double? = nil + ) { self.leftRatio = leftRatio self.rightRatio = rightRatio self.leftVerticalRatio = leftVerticalRatio self.rightVerticalRatio = rightVerticalRatio self.faceWidthRatio = faceWidthRatio - - // Calculate average horizontal ratio - if let left = leftRatio, let right = rightRatio { - self.averageRatio = (left + right) / 2.0 - } else { - self.averageRatio = leftRatio ?? rightRatio ?? 0.5 - } - - // Calculate average vertical ratio - if let left = leftVerticalRatio, let right = rightVerticalRatio { - self.averageVerticalRatio = (left + right) / 2.0 - } else { - self.averageVerticalRatio = leftVerticalRatio ?? rightVerticalRatio ?? 0.5 - } - + + self.averageRatio = GazeSample.average(left: leftRatio, right: rightRatio, fallback: 0.5) + self.averageVerticalRatio = GazeSample.average( + left: leftVerticalRatio, + right: rightVerticalRatio, + fallback: 0.5 + ) + self.timestamp = Date() } + + private static func average(left: Double?, right: Double?, fallback: Double) -> Double { + switch (left, right) { + case let (left?, right?): + return (left + right) / 2.0 + case let (left?, nil): + return left + case let (nil, right?): + return right + default: + return fallback + } + } } struct GazeThresholds: Codable { // Horizontal Thresholds - let minLeftRatio: Double // Looking left (≥ value) + let minLeftRatio: Double // Looking left (≥ value) let maxRightRatio: Double // Looking right (≤ value) - + // Vertical Thresholds - let minUpRatio: Double // Looking up (≤ value, typically < 0.5) - let maxDownRatio: Double // Looking down (≥ value, typically > 0.5) - + let minUpRatio: Double // Looking up (≤ value, typically < 0.5) + let maxDownRatio: Double // Looking down (≥ value, typically > 0.5) + // Screen Bounds (Calibration Zone) // Defines the rectangle of pupil ratios that correspond to looking AT the screen let screenLeftBound: Double let screenRightBound: Double let screenTopBound: Double let screenBottomBound: Double - + // Reference Data for Distance Scaling - let referenceFaceWidth: Double // Average face width during calibration - + let referenceFaceWidth: Double // Average face width during calibration + var isValid: Bool { - // Just check that we have reasonable values (not NaN or infinite) - let values = [minLeftRatio, maxRightRatio, minUpRatio, maxDownRatio, - screenLeftBound, screenRightBound, screenTopBound, screenBottomBound] - return values.allSatisfy { $0.isFinite } + isFiniteValues([ + minLeftRatio, maxRightRatio, minUpRatio, maxDownRatio, + screenLeftBound, screenRightBound, screenTopBound, screenBottomBound, + ]) } - + + private func isFiniteValues(_ values: [Double]) -> Bool { + values.allSatisfy { $0.isFinite } + } + /// Default thresholds based on video test data: /// - Center (looking at screen): H ≈ 0.29-0.35 /// - Screen left edge: H ≈ 0.45-0.50 @@ -136,14 +152,14 @@ struct GazeThresholds: Codable { /// Coordinate system: Lower H = right, Higher H = left static var defaultThresholds: GazeThresholds { GazeThresholds( - minLeftRatio: 0.55, // Beyond this = looking left (away) - maxRightRatio: 0.15, // Below this = looking right (away) - minUpRatio: 0.30, // Below this = looking up (away) - maxDownRatio: 0.60, // Above this = looking down (away) - screenLeftBound: 0.50, // Left edge of screen + minLeftRatio: 0.55, // Beyond this = looking left (away) + maxRightRatio: 0.15, // Below this = looking right (away) + minUpRatio: 0.30, // Below this = looking up (away) + maxDownRatio: 0.60, // Above this = looking down (away) + screenLeftBound: 0.50, // Left edge of screen screenRightBound: 0.20, // Right edge of screen - screenTopBound: 0.35, // Top edge of screen - screenBottomBound: 0.55, // Bottom edge of screen + screenTopBound: 0.35, // Top edge of screen + screenBottomBound: 0.55, // Bottom edge of screen referenceFaceWidth: 0.4566 // Measured from test videos (avg of inner/outer) ) } @@ -154,191 +170,67 @@ struct CalibrationData: Codable { var computedThresholds: GazeThresholds? var calibrationDate: Date var isComplete: Bool - + private let thresholdCalculator = CalibrationThresholdCalculator() + + enum CodingKeys: String, CodingKey { + case samples + case computedThresholds + case calibrationDate + case isComplete + } + init() { self.samples = [:] self.computedThresholds = nil self.calibrationDate = Date() self.isComplete = false } - + mutating func addSample(_ sample: GazeSample, for step: CalibrationStep) { if samples[step] == nil { samples[step] = [] } samples[step]?.append(sample) } - + func getSamples(for step: CalibrationStep) -> [GazeSample] { return samples[step] ?? [] } - + func averageRatio(for step: CalibrationStep) -> Double? { let stepSamples = getSamples(for: step) guard !stepSamples.isEmpty else { return nil } return stepSamples.reduce(0.0) { $0 + $1.averageRatio } / Double(stepSamples.count) } - + func averageVerticalRatio(for step: CalibrationStep) -> Double? { let stepSamples = getSamples(for: step) guard !stepSamples.isEmpty else { return nil } return stepSamples.reduce(0.0) { $0 + $1.averageVerticalRatio } / Double(stepSamples.count) } - + func averageFaceWidth(for step: CalibrationStep) -> Double? { let stepSamples = getSamples(for: step) let validSamples = stepSamples.compactMap { $0.faceWidthRatio } guard !validSamples.isEmpty else { return nil } return validSamples.reduce(0.0, +) / Double(validSamples.count) } - + mutating func calculateThresholds() { - // Calibration uses actual measured gaze ratios from the user looking at different - // screen positions. The face width during calibration serves as a reference for - // distance-based normalization during live tracking. - // - // Coordinate system (based on video testing): - // Horizontal: 0.0 = far right, 1.0 = far left - // Vertical: 0.0 = top, 1.0 = bottom - // Center (looking at screen) typically: H ≈ 0.29-0.35 - - // 1. Get center reference point - let centerH = averageRatio(for: .center) - let centerV = averageVerticalRatio(for: .center) - let centerFaceWidth = averageFaceWidth(for: .center) - - guard let cH = centerH else { - print("⚠️ No center calibration data, using defaults") - self.computedThresholds = GazeThresholds.defaultThresholds - return - } - - let cV = centerV ?? 0.45 // Default vertical center - - print("📊 Calibration data collected:") - print(" Center H: \(String(format: "%.3f", cH)), V: \(String(format: "%.3f", cV))") - - // 2. Get horizontal screen bounds from left/right calibration points - // These represent where the user looked when targeting screen edges - // Use farLeft/farRight for "beyond screen" thresholds, left/right for screen bounds - - // Screen bounds (where user looked at screen edges) - let screenLeftH = averageRatio(for: .left) - ?? averageRatio(for: .topLeft) - ?? averageRatio(for: .bottomLeft) - let screenRightH = averageRatio(for: .right) - ?? averageRatio(for: .topRight) - ?? averageRatio(for: .bottomRight) - - // Far bounds (where user looked beyond screen - for "looking away" threshold) - let farLeftH = averageRatio(for: .farLeft) - let farRightH = averageRatio(for: .farRight) - - // 3. Calculate horizontal thresholds - // If we have farLeft/farRight, use the midpoint between screen edge and far as threshold - // Otherwise, extend screen bounds by a margin - - let leftBound: Double - let rightBound: Double - let lookLeftThreshold: Double - let lookRightThreshold: Double - - if let sLeft = screenLeftH { - leftBound = sLeft - // If we have farLeft, threshold is midpoint; otherwise extend by margin - if let fLeft = farLeftH { - lookLeftThreshold = (sLeft + fLeft) / 2.0 - } else { - // Extend beyond screen by ~50% of center-to-edge distance - let edgeDistance = sLeft - cH - lookLeftThreshold = sLeft + edgeDistance * 0.5 - } - } else { - // No left calibration - estimate based on center - leftBound = cH + 0.15 - lookLeftThreshold = cH + 0.20 - } - - if let sRight = screenRightH { - rightBound = sRight - if let fRight = farRightH { - lookRightThreshold = (sRight + fRight) / 2.0 - } else { - let edgeDistance = cH - sRight - lookRightThreshold = sRight - edgeDistance * 0.5 - } - } else { - rightBound = cH - 0.15 - lookRightThreshold = cH - 0.20 - } - - // 4. Get vertical screen bounds - let screenTopV = averageVerticalRatio(for: .up) - ?? averageVerticalRatio(for: .topLeft) - ?? averageVerticalRatio(for: .topRight) - let screenBottomV = averageVerticalRatio(for: .down) - ?? averageVerticalRatio(for: .bottomLeft) - ?? averageVerticalRatio(for: .bottomRight) - - let topBound: Double - let bottomBound: Double - let lookUpThreshold: Double - let lookDownThreshold: Double - - if let sTop = screenTopV { - topBound = sTop - let edgeDistance = cV - sTop - lookUpThreshold = sTop - edgeDistance * 0.5 - } else { - topBound = cV - 0.10 - lookUpThreshold = cV - 0.15 - } - - if let sBottom = screenBottomV { - bottomBound = sBottom - let edgeDistance = sBottom - cV - lookDownThreshold = sBottom + edgeDistance * 0.5 - } else { - bottomBound = cV + 0.10 - lookDownThreshold = cV + 0.15 - } - - // 5. Reference face width for distance normalization - // Average face width from all calibration steps gives a good reference - let allFaceWidths = CalibrationStep.allCases.compactMap { averageFaceWidth(for: $0) } - let refFaceWidth = allFaceWidths.isEmpty ? 0.0 : allFaceWidths.reduce(0.0, +) / Double(allFaceWidths.count) - - // 6. Create thresholds - let thresholds = GazeThresholds( - minLeftRatio: lookLeftThreshold, - maxRightRatio: lookRightThreshold, - minUpRatio: lookUpThreshold, - maxDownRatio: lookDownThreshold, - screenLeftBound: leftBound, - screenRightBound: rightBound, - screenTopBound: topBound, - screenBottomBound: bottomBound, - referenceFaceWidth: refFaceWidth - ) - - self.computedThresholds = thresholds - - print("✓ Calibration thresholds calculated:") - print(" Center: H=\(String(format: "%.3f", cH)), V=\(String(format: "%.3f", cV))") - print(" Screen H-Range: \(String(format: "%.3f", rightBound)) to \(String(format: "%.3f", leftBound))") - print(" Screen V-Range: \(String(format: "%.3f", topBound)) to \(String(format: "%.3f", bottomBound))") - print(" Away Thresholds: L≥\(String(format: "%.3f", lookLeftThreshold)), R≤\(String(format: "%.3f", lookRightThreshold))") - print(" Away Thresholds: U≤\(String(format: "%.3f", lookUpThreshold)), D≥\(String(format: "%.3f", lookDownThreshold))") - print(" Ref Face Width: \(String(format: "%.3f", refFaceWidth))") - - // Log per-step data for debugging + self.computedThresholds = thresholdCalculator.calculate(using: self) + logStepData() + } + + private func logStepData() { print(" Per-step data:") for step in CalibrationStep.allCases { if let h = averageRatio(for: step) { let v = averageVerticalRatio(for: step) ?? -1 let fw = averageFaceWidth(for: step) ?? -1 let count = getSamples(for: step).count - print(" \(step.rawValue): H=\(String(format: "%.3f", h)), V=\(String(format: "%.3f", v)), FW=\(String(format: "%.3f", fw)), samples=\(count)") + print( + " \(step.rawValue): H=\(String(format: "%.3f", h)), V=\(String(format: "%.3f", v)), FW=\(String(format: "%.3f", fw)), samples=\(count)" + ) } } } @@ -351,14 +243,39 @@ class CalibrationState: @unchecked Sendable { private let queue = DispatchQueue(label: "com.gaze.calibrationState", attributes: .concurrent) private var _thresholds: GazeThresholds? private var _isComplete: Bool = false - + var thresholds: GazeThresholds? { get { queue.sync { _thresholds } } set { queue.async(flags: .barrier) { self._thresholds = newValue } } } - + var isComplete: Bool { get { queue.sync { _isComplete } } set { queue.async(flags: .barrier) { self._isComplete = newValue } } } + + func reset() { + setState(thresholds: nil, isComplete: false) + } + + func setThresholds(_ thresholds: GazeThresholds?) { + setState(thresholds: thresholds, isComplete: nil) + } + + func setComplete(_ isComplete: Bool) { + setState(thresholds: nil, isComplete: isComplete) + } + + private func setState(thresholds: GazeThresholds?, isComplete: Bool?) { + queue.async(flags: .barrier) { + if let thresholds { + self._thresholds = thresholds + } else if isComplete == nil { + self._thresholds = nil + } + if let isComplete { + self._isComplete = isComplete + } + } + } } diff --git a/Gaze/Models/CalibrationThresholdCalculator.swift b/Gaze/Models/CalibrationThresholdCalculator.swift new file mode 100644 index 0000000..7a1034e --- /dev/null +++ b/Gaze/Models/CalibrationThresholdCalculator.swift @@ -0,0 +1,158 @@ +// +// CalibrationThresholdCalculator.swift +// Gaze +// +// Created by Mike Freno on 1/29/26. +// + +import Foundation + +struct CalibrationThresholdCalculator { + func calculate(using data: CalibrationData) -> GazeThresholds? { + let centerH = data.averageRatio(for: .center) + let centerV = data.averageVerticalRatio(for: .center) + + guard let cH = centerH else { + print("⚠️ No center calibration data, using defaults") + return GazeThresholds.defaultThresholds + } + + let cV = centerV ?? 0.45 + + print("📊 Calibration data collected:") + print(" Center H: \(String(format: "%.3f", cH)), V: \(String(format: "%.3f", cV))") + + let screenLeftH = data.averageRatio(for: .left) + ?? data.averageRatio(for: .topLeft) + ?? data.averageRatio(for: .bottomLeft) + let screenRightH = data.averageRatio(for: .right) + ?? data.averageRatio(for: .topRight) + ?? data.averageRatio(for: .bottomRight) + + let farLeftH = data.averageRatio(for: .farLeft) + let farRightH = data.averageRatio(for: .farRight) + + let (leftBound, lookLeftThreshold) = horizontalBounds( + center: cH, + screenEdge: screenLeftH, + farEdge: farLeftH, + direction: .left + ) + let (rightBound, lookRightThreshold) = horizontalBounds( + center: cH, + screenEdge: screenRightH, + farEdge: farRightH, + direction: .right + ) + + let screenTopV = data.averageVerticalRatio(for: .up) + ?? data.averageVerticalRatio(for: .topLeft) + ?? data.averageVerticalRatio(for: .topRight) + let screenBottomV = data.averageVerticalRatio(for: .down) + ?? data.averageVerticalRatio(for: .bottomLeft) + ?? data.averageVerticalRatio(for: .bottomRight) + + let (topBound, lookUpThreshold) = verticalBounds(center: cV, screenEdge: screenTopV, isUpperEdge: true) + let (bottomBound, lookDownThreshold) = verticalBounds( + center: cV, + screenEdge: screenBottomV, + isUpperEdge: false + ) + + let allFaceWidths = CalibrationStep.allCases.compactMap { data.averageFaceWidth(for: $0) } + let refFaceWidth = allFaceWidths.isEmpty ? 0.0 : allFaceWidths.average() + + let thresholds = GazeThresholds( + minLeftRatio: lookLeftThreshold, + maxRightRatio: lookRightThreshold, + minUpRatio: lookUpThreshold, + maxDownRatio: lookDownThreshold, + screenLeftBound: leftBound, + screenRightBound: rightBound, + screenTopBound: topBound, + screenBottomBound: bottomBound, + referenceFaceWidth: refFaceWidth + ) + + logThresholds( + thresholds: thresholds, + centerHorizontal: cH, + centerVertical: cV + ) + + return thresholds + } + + private enum HorizontalDirection { + case left + case right + } + + private func horizontalBounds( + center: Double, + screenEdge: Double?, + farEdge: Double?, + direction: HorizontalDirection + ) -> (bound: Double, threshold: Double) { + let defaultBoundOffset = direction == .left ? 0.15 : -0.15 + let defaultThresholdOffset = direction == .left ? 0.20 : -0.20 + + guard let screenEdge = screenEdge else { + return (center + defaultBoundOffset, center + defaultThresholdOffset) + } + + let bound = screenEdge + let threshold: Double + if let farEdge = farEdge { + threshold = (screenEdge + farEdge) / 2.0 + } else { + threshold = screenEdge + defaultThresholdOffset + } + + return (bound, threshold) + } + + private func verticalBounds(center: Double, screenEdge: Double?, isUpperEdge: Bool) -> (bound: Double, threshold: Double) { + let defaultBoundOffset = isUpperEdge ? -0.10 : 0.10 + let defaultThresholdOffset = isUpperEdge ? -0.15 : 0.15 + + guard let screenEdge = screenEdge else { + return (center + defaultBoundOffset, center + defaultThresholdOffset) + } + + let bound = screenEdge + let edgeDistance = isUpperEdge ? center - screenEdge : screenEdge - center + let threshold = isUpperEdge ? screenEdge - (edgeDistance * 0.5) : screenEdge + (edgeDistance * 0.5) + + return (bound, threshold) + } + + private func logThresholds( + thresholds: GazeThresholds, + centerHorizontal: Double, + centerVertical: Double + ) { + print("✓ Calibration thresholds calculated:") + print(" Center: H=\(String(format: "%.3f", centerHorizontal)), V=\(String(format: "%.3f", centerVertical))") + print( + " Screen H-Range: \(String(format: "%.3f", thresholds.screenRightBound)) to \(String(format: "%.3f", thresholds.screenLeftBound))" + ) + print( + " Screen V-Range: \(String(format: "%.3f", thresholds.screenTopBound)) to \(String(format: "%.3f", thresholds.screenBottomBound))" + ) + print( + " Away Thresholds: L≥\(String(format: "%.3f", thresholds.minLeftRatio)), R≤\(String(format: "%.3f", thresholds.maxRightRatio))" + ) + print( + " Away Thresholds: U≤\(String(format: "%.3f", thresholds.minUpRatio)), D≥\(String(format: "%.3f", thresholds.maxDownRatio))" + ) + print(" Ref Face Width: \(String(format: "%.3f", thresholds.referenceFaceWidth))") + } +} + +private extension Array where Element == Double { + func average() -> Double { + guard !isEmpty else { return 0 } + return reduce(0.0, +) / Double(count) + } +} diff --git a/Gaze/Models/DefaultSettingsBuilder.swift b/Gaze/Models/DefaultSettingsBuilder.swift new file mode 100644 index 0000000..05f01ef --- /dev/null +++ b/Gaze/Models/DefaultSettingsBuilder.swift @@ -0,0 +1,39 @@ +// +// DefaultSettingsBuilder.swift +// Gaze +// +// Created by Mike Freno on 1/29/26. +// + +import Foundation + +struct DefaultSettingsBuilder { + static let lookAwayEnabled = true + static let lookAwayIntervalMinutes = 20 + static let blinkEnabled = false + static let blinkIntervalMinutes = 7 + static let postureEnabled = true + static let postureIntervalMinutes = 30 + static let subtleReminderSize: ReminderSize = .medium + static let smartMode: SmartModeSettings = .defaults + static let hasCompletedOnboarding = false + static let launchAtLogin = false + static let playSounds = true + + static func makeDefaults() -> AppSettings { + AppSettings( + lookAwayEnabled: lookAwayEnabled, + lookAwayIntervalMinutes: lookAwayIntervalMinutes, + blinkEnabled: blinkEnabled, + blinkIntervalMinutes: blinkIntervalMinutes, + postureEnabled: postureEnabled, + postureIntervalMinutes: postureIntervalMinutes, + userTimers: [], + subtleReminderSize: subtleReminderSize, + smartMode: smartMode, + hasCompletedOnboarding: hasCompletedOnboarding, + launchAtLogin: launchAtLogin, + playSounds: playSounds + ) + } +} diff --git a/Gaze/Models/TimerState.swift b/Gaze/Models/TimerState.swift index a7a2685..7613ce9 100644 --- a/Gaze/Models/TimerState.swift +++ b/Gaze/Models/TimerState.swift @@ -13,16 +13,55 @@ struct TimerState: Equatable, Hashable { var isPaused: Bool var pauseReasons: Set var isActive: Bool - var targetDate: Date let originalIntervalSeconds: Int + let lastResetDate: Date - init(identifier: TimerIdentifier, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) { - self.identifier = identifier - self.remainingSeconds = intervalSeconds - self.isPaused = isPaused - self.pauseReasons = [] - self.isActive = isActive - self.targetDate = Date().addingTimeInterval(Double(intervalSeconds)) - self.originalIntervalSeconds = intervalSeconds + func targetDate(using timeProvider: TimeProviding) -> Date { + lastResetDate.addingTimeInterval(Double(originalIntervalSeconds)) + } + + var remainingDuration: TimeInterval { + TimeInterval(remainingSeconds) + } + + func isExpired(using timeProvider: TimeProviding) -> Bool { + targetDate(using: timeProvider) <= timeProvider.now() + } + + var formattedDuration: String { + remainingDuration.formatAsTimerDurationFull() + } + + mutating func reset(intervalSeconds: Int? = nil, keepPaused: Bool = true) { + let newIntervalSeconds = intervalSeconds ?? originalIntervalSeconds + let newPauseReasons = keepPaused ? pauseReasons : [] + self = TimerStateBuilder.make( + identifier: identifier, + intervalSeconds: newIntervalSeconds, + isPaused: keepPaused ? isPaused : false, + pauseReasons: newPauseReasons, + isActive: isActive + ) + } +} + +enum TimerStateBuilder { + static func make( + identifier: TimerIdentifier, + intervalSeconds: Int, + isPaused: Bool = false, + pauseReasons: Set = [], + isActive: Bool = true, + lastResetDate: Date = Date() + ) -> TimerState { + TimerState( + identifier: identifier, + remainingSeconds: intervalSeconds, + isPaused: isPaused, + pauseReasons: pauseReasons, + isActive: isActive, + originalIntervalSeconds: intervalSeconds, + lastResetDate: lastResetDate + ) } } diff --git a/Gaze/Protocols/SettingsProviding.swift b/Gaze/Protocols/SettingsProviding.swift index df8dd22..fbf926a 100644 --- a/Gaze/Protocols/SettingsProviding.swift +++ b/Gaze/Protocols/SettingsProviding.swift @@ -7,13 +7,17 @@ import Combine import Foundation @MainActor -protocol SettingsProviding: AnyObject, Observable { +protocol TimerSettingsProviding { + func allTimerSettings() -> [TimerType: (enabled: Bool, intervalMinutes: Int)] + func isTimerEnabled(for type: TimerType) -> Bool + func timerIntervalMinutes(for type: TimerType) -> Int +} + +@MainActor +protocol SettingsProviding: AnyObject, Observable, TimerSettingsProviding { var settings: AppSettings { get set } var settingsPublisher: AnyPublisher { get } - - func timerConfiguration(for type: TimerType) -> TimerConfiguration - func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) - func allTimerConfigurations() -> [TimerType: TimerConfiguration] + func save() func saveImmediately() func load() diff --git a/Gaze/Services/CalibrationFlowController.swift b/Gaze/Services/CalibrationFlowController.swift new file mode 100644 index 0000000..356f94f --- /dev/null +++ b/Gaze/Services/CalibrationFlowController.swift @@ -0,0 +1,84 @@ +// +// CalibrationFlowController.swift +// Gaze +// +// Created by Mike Freno on 1/29/26. +// + +import Combine +import Foundation + +@MainActor +final class CalibrationFlowController: ObservableObject { + @Published private(set) var currentStep: CalibrationStep? + @Published private(set) var currentStepIndex = 0 + @Published private(set) var isCollectingSamples = false + @Published private(set) var samplesCollected = 0 + + private let samplesPerStep: Int + private let calibrationSteps: [CalibrationStep] + + init(samplesPerStep: Int, calibrationSteps: [CalibrationStep]) { + self.samplesPerStep = samplesPerStep + self.calibrationSteps = calibrationSteps + self.currentStep = calibrationSteps.first + } + + func start() { + isCollectingSamples = false + currentStepIndex = 0 + currentStep = calibrationSteps.first + samplesCollected = 0 + } + + func stop() { + isCollectingSamples = false + currentStep = nil + currentStepIndex = 0 + samplesCollected = 0 + } + + func startCollectingSamples() { + guard currentStep != nil else { return } + isCollectingSamples = true + } + + func resetSamples() { + isCollectingSamples = false + samplesCollected = 0 + } + + func markSampleCollected() -> Bool { + samplesCollected += 1 + return samplesCollected >= samplesPerStep + } + + func advanceToNextStep() -> Bool { + isCollectingSamples = false + currentStepIndex += 1 + + guard currentStepIndex < calibrationSteps.count else { + currentStep = nil + return false + } + + currentStep = calibrationSteps[currentStepIndex] + samplesCollected = 0 + return true + } + + func skipStep() -> Bool { + advanceToNextStep() + } + + var progress: Double { + let totalSteps = calibrationSteps.count + guard totalSteps > 0 else { return 0 } + let currentProgress = Double(samplesCollected) / Double(samplesPerStep) + return (Double(currentStepIndex) + currentProgress) / Double(totalSteps) + } + + var progressText: String { + "\(min(currentStepIndex + 1, calibrationSteps.count)) of \(calibrationSteps.count)" + } +} diff --git a/Gaze/Services/CalibrationManager.swift b/Gaze/Services/CalibrationManager.swift index 4af4e51..f78323d 100644 --- a/Gaze/Services/CalibrationManager.swift +++ b/Gaze/Services/CalibrationManager.swift @@ -5,8 +5,8 @@ // Created by Mike Freno on 1/15/26. // -import Foundation import Combine +import Foundation @MainActor class CalibrationManager: ObservableObject { @@ -22,11 +22,10 @@ class CalibrationManager: ObservableObject { @Published var calibrationData = CalibrationData() // MARK: - Configuration - + private let samplesPerStep = 30 // Collect 30 samples per calibration point (~1 second at 30fps) private let userDefaultsKey = "eyeTrackingCalibration" - - // Calibration sequence (9 steps) + private let calibrationSteps: [CalibrationStep] = [ .center, .left, @@ -38,11 +37,30 @@ class CalibrationManager: ObservableObject { .topLeft, .topRight ] + + private let flowController: CalibrationFlowController + private var sampleCollector = CalibrationSampleCollector() // MARK: - Initialization private init() { + self.flowController = CalibrationFlowController( + samplesPerStep: samplesPerStep, + calibrationSteps: calibrationSteps + ) loadCalibration() + bindFlowController() + } + + private func bindFlowController() { + flowController.$isCollectingSamples + .assign(to: &$isCollectingSamples) + flowController.$currentStep + .assign(to: &$currentStep) + flowController.$currentStepIndex + .assign(to: &$currentStepIndex) + flowController.$samplesCollected + .assign(to: &$samplesCollected) } // MARK: - Calibration Flow @@ -50,10 +68,7 @@ class CalibrationManager: ObservableObject { func startCalibration() { print("🎯 Starting calibration...") isCalibrating = true - isCollectingSamples = false - currentStepIndex = 0 - currentStep = calibrationSteps[0] - samplesCollected = 0 + flowController.start() calibrationData = CalibrationData() } @@ -61,44 +76,43 @@ class CalibrationManager: ObservableObject { func resetForNewCalibration() { print("🔄 Resetting for new calibration...") calibrationData = CalibrationData() + flowController.start() } func startCollectingSamples() { - guard isCalibrating, currentStep != nil else { return } + guard isCalibrating else { return } print("📊 Started collecting samples for step: \(currentStep?.displayName ?? "unknown")") - isCollectingSamples = true + flowController.startCollectingSamples() } - func collectSample(leftRatio: Double?, rightRatio: Double?, leftVertical: Double? = nil, rightVertical: Double? = nil, faceWidthRatio: Double? = nil) { + func collectSample( + leftRatio: Double?, + rightRatio: Double?, + leftVertical: Double? = nil, + rightVertical: Double? = nil, + faceWidthRatio: Double? = nil + ) { guard isCalibrating, isCollectingSamples, let step = currentStep else { return } - - let sample = GazeSample( + + sampleCollector.addSample( + to: &calibrationData, + step: step, leftRatio: leftRatio, rightRatio: rightRatio, - leftVerticalRatio: leftVertical, - rightVerticalRatio: rightVertical, + leftVertical: leftVertical, + rightVertical: rightVertical, faceWidthRatio: faceWidthRatio ) - calibrationData.addSample(sample, for: step) - samplesCollected += 1 - - // Move to next step when enough samples collected - if samplesCollected >= samplesPerStep { + + if flowController.markSampleCollected() { advanceToNextStep() } } private func advanceToNextStep() { - isCollectingSamples = false - currentStepIndex += 1 - - if currentStepIndex < calibrationSteps.count { - // Move to next calibration point - currentStep = calibrationSteps[currentStepIndex] - samplesCollected = 0 + if flowController.advanceToNextStep() { print("📍 Calibration step: \(currentStep?.displayName ?? "unknown")") } else { - // All steps complete finishCalibration() } } @@ -122,10 +136,7 @@ class CalibrationManager: ObservableObject { applyCalibration() isCalibrating = false - isCollectingSamples = false - currentStep = nil - currentStepIndex = 0 - samplesCollected = 0 + flowController.stop() print("✓ Calibration saved and applied") } @@ -133,15 +144,10 @@ class CalibrationManager: ObservableObject { func cancelCalibration() { print("❌ Calibration cancelled") isCalibrating = false - isCollectingSamples = false - currentStep = nil - currentStepIndex = 0 - samplesCollected = 0 + flowController.stop() calibrationData = CalibrationData() - // Reset thread-safe state - CalibrationState.shared.isComplete = false - CalibrationState.shared.thresholds = nil + CalibrationState.shared.reset() } // MARK: - Persistence @@ -184,9 +190,7 @@ class CalibrationManager: ObservableObject { UserDefaults.standard.removeObject(forKey: userDefaultsKey) calibrationData = CalibrationData() - // Reset thread-safe state - CalibrationState.shared.isComplete = false - CalibrationState.shared.thresholds = nil + CalibrationState.shared.reset() print("🗑️ Calibration data cleared") } @@ -215,8 +219,8 @@ class CalibrationManager: ObservableObject { } // Push to thread-safe state for background processing - CalibrationState.shared.thresholds = thresholds - CalibrationState.shared.isComplete = true + CalibrationState.shared.setThresholds(thresholds) + CalibrationState.shared.setComplete(true) print("✓ Applied calibrated thresholds:") print(" Looking left: ≥\(String(format: "%.3f", thresholds.minLeftRatio))") @@ -251,13 +255,10 @@ class CalibrationManager: ObservableObject { // MARK: - Progress var progress: Double { - let totalSteps = calibrationSteps.count - let completedSteps = currentStepIndex - let currentProgress = Double(samplesCollected) / Double(samplesPerStep) - return (Double(completedSteps) + currentProgress) / Double(totalSteps) + flowController.progress } var progressText: String { - "\(currentStepIndex + 1) of \(calibrationSteps.count)" + flowController.progressText } } diff --git a/Gaze/Services/CalibrationSampleCollector.swift b/Gaze/Services/CalibrationSampleCollector.swift new file mode 100644 index 0000000..b38ab11 --- /dev/null +++ b/Gaze/Services/CalibrationSampleCollector.swift @@ -0,0 +1,29 @@ +// +// CalibrationSampleCollector.swift +// Gaze +// +// Created by Mike Freno on 1/29/26. +// + +import Foundation + +struct CalibrationSampleCollector { + mutating func addSample( + to data: inout CalibrationData, + step: CalibrationStep, + leftRatio: Double?, + rightRatio: Double?, + leftVertical: Double?, + rightVertical: Double?, + faceWidthRatio: Double? + ) { + let sample = GazeSample( + leftRatio: leftRatio, + rightRatio: rightRatio, + leftVerticalRatio: leftVertical, + rightVerticalRatio: rightVertical, + faceWidthRatio: faceWidthRatio + ) + data.addSample(sample, for: step) + } +} diff --git a/Gaze/Services/EnforceMode/EnforceCameraController.swift b/Gaze/Services/EnforceMode/EnforceCameraController.swift index 513b260..0e8d8af 100644 --- a/Gaze/Services/EnforceMode/EnforceCameraController.swift +++ b/Gaze/Services/EnforceMode/EnforceCameraController.swift @@ -69,11 +69,13 @@ final class EnforceCameraController: ObservableObject { private func startFaceDetectionTimer() { stopFaceDetectionTimer() - faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - Task { @MainActor in + + faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + Task { @MainActor [weak self] in self?.checkFaceDetectionTimeout() } } + } private func stopFaceDetectionTimer() { diff --git a/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift b/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift index 17f9d6e..a3d732e 100644 --- a/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift +++ b/Gaze/Services/EnforceMode/EnforcePolicyEvaluator.swift @@ -21,7 +21,7 @@ final class EnforcePolicyEvaluator { } var isEnforcementEnabled: Bool { - settingsProvider.settings.enforcementMode + settingsProvider.isTimerEnabled(for: .lookAway) } func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool { diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index d1a99a2..4e0ef95 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -42,10 +42,10 @@ class EyeTrackingService: NSObject, ObservableObject { @Published var debugImageSize: CGSize? private let cameraManager = CameraSessionManager() - nonisolated(unsafe) private let visionPipeline = VisionPipeline() + private let visionPipeline = VisionPipeline() private let debugAdapter = EyeDebugStateAdapter() private let calibrationBridge = CalibrationBridge() - nonisolated(unsafe) private let gazeDetector: GazeDetector + private let gazeDetector: GazeDetector var previewLayer: AVCaptureVideoPreviewLayer? { cameraManager.previewLayer diff --git a/Gaze/Services/FullscreenDetectionService.swift b/Gaze/Services/FullscreenDetectionService.swift index 4f82366..a40124a 100644 --- a/Gaze/Services/FullscreenDetectionService.swift +++ b/Gaze/Services/FullscreenDetectionService.swift @@ -70,6 +70,7 @@ final class FullscreenDetectionService: ObservableObject { private var frontmostAppObserver: AnyCancellable? private let permissionManager: ScreenCapturePermissionManaging private let environmentProvider: FullscreenEnvironmentProviding + private let windowMatcher = FullscreenWindowMatcher() init( permissionManager: ScreenCapturePermissionManaging, @@ -111,49 +112,27 @@ final class FullscreenDetectionService: ObservableObject { let workspace = NSWorkspace.shared let notificationCenter = workspace.notificationCenter - let spaceObserver = notificationCenter.addObserver( - forName: NSWorkspace.activeSpaceDidChangeNotification, - object: workspace, - queue: .main - ) { [weak self] _ in + let stateChangeHandler: (Notification) -> Void = { [weak self] _ in Task { @MainActor in self?.checkFullscreenState() } } - observers.append(spaceObserver) - let transitionObserver = notificationCenter.addObserver( - forName: NSApplication.didChangeScreenParametersNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.checkFullscreenState() - } - } - observers.append(transitionObserver) + let notifications: [(NSNotification.Name, Any?)] = [ + (NSWorkspace.activeSpaceDidChangeNotification, workspace), + (NSApplication.didChangeScreenParametersNotification, nil), + (NSWindow.willEnterFullScreenNotification, nil), + (NSWindow.willExitFullScreenNotification, nil), + ] - let fullscreenObserver = notificationCenter.addObserver( - forName: NSWindow.willEnterFullScreenNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.checkFullscreenState() - } + observers = notifications.map { notification, object in + notificationCenter.addObserver( + forName: notification, + object: object, + queue: .main, + using: stateChangeHandler + ) } - observers.append(fullscreenObserver) - - let exitFullscreenObserver = notificationCenter.addObserver( - forName: NSWindow.willExitFullScreenNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.checkFullscreenState() - } - } - observers.append(exitFullscreenObserver) frontmostAppObserver = NotificationCenter.default.publisher( for: NSWorkspace.didActivateApplicationNotification, @@ -187,7 +166,7 @@ final class FullscreenDetectionService: ObservableObject { let screens = environmentProvider.screenFrames() for window in windows where window.ownerPID == frontmostPID && window.layer == 0 { - if screens.contains(where: { FullscreenDetectionService.window(window.bounds, matches: $0) }) { + if windowMatcher.isFullscreen(windowBounds: window.bounds, screenFrames: screens) { setFullscreenState(true) return } @@ -196,13 +175,6 @@ final class FullscreenDetectionService: ObservableObject { setFullscreenState(false) } - private static func window(_ windowBounds: CGRect, matches screenFrame: CGRect, tolerance: CGFloat = 1) -> Bool { - abs(windowBounds.width - screenFrame.width) < tolerance && - abs(windowBounds.height - screenFrame.height) < tolerance && - abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance && - abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance - } - fileprivate func setFullscreenState(_ isActive: Bool) { guard isFullscreenActive != isActive else { return } isFullscreenActive = isActive diff --git a/Gaze/Services/FullscreenWindowMatcher.swift b/Gaze/Services/FullscreenWindowMatcher.swift new file mode 100644 index 0000000..5e8727e --- /dev/null +++ b/Gaze/Services/FullscreenWindowMatcher.swift @@ -0,0 +1,21 @@ +// +// FullscreenWindowMatcher.swift +// Gaze +// +// Created by Mike Freno on 1/29/26. +// + +import CoreGraphics + +struct FullscreenWindowMatcher { + func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool { + screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) } + } + + private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool { + abs(windowBounds.width - screenFrame.width) < tolerance + && abs(windowBounds.height - screenFrame.height) < tolerance + && abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance + && abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance + } +} diff --git a/Gaze/Services/ReminderManager.swift b/Gaze/Services/ReminderManager.swift index f9f8127..4e5478d 100644 --- a/Gaze/Services/ReminderManager.swift +++ b/Gaze/Services/ReminderManager.swift @@ -38,7 +38,7 @@ class ReminderManager: ObservableObject { switch type { case .lookAway: activeReminder = .lookAwayTriggered( - countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds) + countdownSeconds: settingsProvider.timerIntervalMinutes(for: .lookAway) * 60) case .blink: activeReminder = .blinkTriggered case .posture: diff --git a/Gaze/Services/SettingsManager.swift b/Gaze/Services/SettingsManager.swift index bb2f6be..fcf4ec3 100644 --- a/Gaze/Services/SettingsManager.swift +++ b/Gaze/Services/SettingsManager.swift @@ -31,10 +31,17 @@ final class SettingsManager { private var saveCancellable: AnyCancellable? @ObservationIgnored - private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ - .lookAway: \.lookAwayTimer, - .blink: \.blinkTimer, - .posture: \.postureTimer, + private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ + .lookAway: \.lookAwayEnabled, + .blink: \.blinkEnabled, + .posture: \.postureEnabled, + ] + + @ObservationIgnored + private let intervalKeyPaths: [TimerType: WritableKeyPath] = [ + .lookAway: \.lookAwayIntervalMinutes, + .blink: \.blinkIntervalMinutes, + .posture: \.postureIntervalMinutes, ] private init() { @@ -83,25 +90,37 @@ final class SettingsManager { settings = .defaults } - func timerConfiguration(for type: TimerType) -> TimerConfiguration { + func isTimerEnabled(for type: TimerType) -> Bool { guard let keyPath = timerConfigKeyPaths[type] else { preconditionFailure("Unknown timer type: \(type)") } return settings[keyPath: keyPath] } - func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { + func updateTimerEnabled(for type: TimerType, enabled: Bool) { guard let keyPath = timerConfigKeyPaths[type] else { preconditionFailure("Unknown timer type: \(type)") } - settings[keyPath: keyPath] = configuration + settings[keyPath: keyPath] = enabled } - func allTimerConfigurations() -> [TimerType: TimerConfiguration] { - var configs: [TimerType: TimerConfiguration] = [:] - for (type, keyPath) in timerConfigKeyPaths { - configs[type] = settings[keyPath: keyPath] + func timerIntervalMinutes(for type: TimerType) -> Int { + guard let keyPath = intervalKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + return settings[keyPath: keyPath] + } + + func updateTimerInterval(for type: TimerType, minutes: Int) { + guard let keyPath = intervalKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + settings[keyPath: keyPath] = minutes + } + + func allTimerSettings() -> [TimerType: (enabled: Bool, intervalMinutes: Int)] { + TimerType.allCases.reduce(into: [:]) { result, type in + result[type] = (enabled: isTimerEnabled(for: type), intervalMinutes: timerIntervalMinutes(for: type)) } - return configs } } diff --git a/Gaze/Services/Timer/ReminderTriggerService.swift b/Gaze/Services/Timer/ReminderTriggerService.swift index 1878142..99d9b4d 100644 --- a/Gaze/Services/Timer/ReminderTriggerService.swift +++ b/Gaze/Services/Timer/ReminderTriggerService.swift @@ -26,7 +26,7 @@ final class ReminderTriggerService { switch type { case .lookAway: return .lookAwayTriggered( - countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds + countdownSeconds: settingsProvider.settings.lookAwayIntervalMinutes * 60 ) case .blink: return .blinkTriggered diff --git a/Gaze/Services/Timer/TimerStateManager.swift b/Gaze/Services/Timer/TimerStateManager.swift index e5d45d2..0295c71 100644 --- a/Gaze/Services/Timer/TimerStateManager.swift +++ b/Gaze/Services/Timer/TimerStateManager.swift @@ -14,85 +14,11 @@ final class TimerStateManager: ObservableObject { @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 + timerStates = buildInitialStates(configurations: configurations, userTimers: userTimers) } 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 + timerStates = buildUpdatedStates(configurations: configurations, userTimers: userTimers) } func decrementTimer(identifier: TimerIdentifier) -> TimerState? { @@ -137,24 +63,83 @@ final class TimerStateManager: ObservableObject { } 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 - ) + guard var state = timerStates[identifier] else { return } + state.reset(intervalSeconds: intervalSeconds, keepPaused: true) + timerStates[identifier] = state } func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval { - guard let state = timerStates[identifier] else { return 0 } - return TimeInterval(state.remainingSeconds) + timerStates[identifier]?.remainingDuration ?? 0 } func isTimerPaused(_ identifier: TimerIdentifier) -> Bool { return timerStates[identifier]?.isPaused ?? true } + private func buildInitialStates( + configurations: [TimerIdentifier: TimerConfiguration], + userTimers: [UserTimer] + ) -> [TimerIdentifier: TimerState] { + var newStates: [TimerIdentifier: TimerState] = [:] + + for (identifier, config) in configurations where config.enabled { + newStates[identifier] = TimerStateBuilder.make( + identifier: identifier, + intervalSeconds: config.intervalSeconds + ) + } + + for userTimer in userTimers where userTimer.enabled { + let identifier = TimerIdentifier.user(id: userTimer.id) + newStates[identifier] = TimerStateBuilder.make( + identifier: identifier, + intervalSeconds: userTimer.intervalMinutes * 60 + ) + } + + return newStates + } + + private func buildUpdatedStates( + configurations: [TimerIdentifier: TimerConfiguration], + userTimers: [UserTimer] + ) -> [TimerIdentifier: TimerState] { + var newStates: [TimerIdentifier: TimerState] = [:] + + for (identifier, config) in configurations { + guard config.enabled else { continue } + newStates[identifier] = resolveState( + identifier: identifier, + intervalSeconds: config.intervalSeconds + ) + } + + for userTimer in userTimers where userTimer.enabled { + let identifier = TimerIdentifier.user(id: userTimer.id) + let intervalSeconds = userTimer.intervalMinutes * 60 + newStates[identifier] = resolveState( + identifier: identifier, + intervalSeconds: intervalSeconds + ) + } + + return newStates + } + + private func resolveState(identifier: TimerIdentifier, intervalSeconds: Int) -> TimerState { + if var existingState = timerStates[identifier] { + if existingState.originalIntervalSeconds != intervalSeconds { + existingState.reset(intervalSeconds: intervalSeconds, keepPaused: true) + } + return existingState + } + + return TimerStateBuilder.make( + identifier: identifier, + intervalSeconds: intervalSeconds + ) + } + func clearAll() { timerStates.removeAll() activeReminder = nil diff --git a/Gaze/Services/TimerConfigurationHelper.swift b/Gaze/Services/TimerConfigurationHelper.swift new file mode 100644 index 0000000..651c552 --- /dev/null +++ b/Gaze/Services/TimerConfigurationHelper.swift @@ -0,0 +1,36 @@ +// +// TimerConfigurationHelper.swift +// Gaze +// +// Created by Mike Freno on 1/29/26. +// + +import Foundation + +struct TimerConfigurationHelper { + let settingsProvider: any SettingsProviding + + func intervalSeconds(for identifier: TimerIdentifier) -> Int { + switch identifier { + case .builtIn(let type): + return settingsProvider.timerIntervalMinutes(for: type) * 60 + case .user(let id): + guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else { + return 0 + } + return userTimer.intervalMinutes * 60 + } + } + + func configurations() -> [TimerIdentifier: TimerConfiguration] { + var configurations: [TimerIdentifier: TimerConfiguration] = [:] + for timerType in TimerType.allCases { + let intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60 + configurations[.builtIn(timerType)] = TimerConfiguration( + enabled: settingsProvider.isTimerEnabled(for: timerType), + intervalSeconds: intervalSeconds + ) + } + return configurations + } +} diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index 0cf509b..9d6cc4f 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -18,6 +18,7 @@ class TimerEngine: ObservableObject { private let stateManager = TimerStateManager() private let scheduler: TimerScheduler private let reminderService: ReminderTriggerService + private let configurationHelper: TimerConfigurationHelper private let smartModeCoordinator = SmartModeCoordinator() private var cancellables = Set() @@ -44,6 +45,7 @@ class TimerEngine: ObservableObject { settingsProvider: settingsManager, enforceModeService: enforceModeService ?? EnforceModeService.shared ) + self.configurationHelper = TimerConfigurationHelper(settingsProvider: settingsManager) Task { @MainActor in enforceModeService?.setTimerEngine(self) @@ -94,7 +96,7 @@ class TimerEngine: ObservableObject { // Initial start - create all timer states stop() stateManager.initializeTimers( - using: timerConfigurations(), + using: configurationHelper.configurations(), userTimers: settingsProvider.settings.userTimers ) scheduler.start() @@ -108,7 +110,7 @@ class TimerEngine: ObservableObject { private func updateConfigurations() { logDebug("Updating timer configurations") stateManager.updateConfigurations( - using: timerConfigurations(), + using: configurationHelper.configurations(), userTimers: settingsProvider.settings.userTimers ) } @@ -141,17 +143,7 @@ class TimerEngine: ObservableObject { /// Unified way to get interval for any timer type private func getTimerInterval(for identifier: TimerIdentifier) -> Int { - switch identifier { - case .builtIn(let type): - let config = settingsProvider.timerConfiguration(for: type) - return config.intervalSeconds - case .user(let id): - guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) - else { - return 0 - } - return userTimer.intervalMinutes * 60 - } + configurationHelper.intervalSeconds(for: identifier) } func dismissReminder() { @@ -170,7 +162,7 @@ class TimerEngine: ObservableObject { guard !state.isPaused else { continue } guard state.isActive else { continue } - if state.targetDate < timeProvider.now() - 3.0 { + if state.targetDate(using: timeProvider) < timeProvider.now() - 3.0 { skipNext(identifier: identifier) continue } @@ -218,13 +210,9 @@ class TimerEngine: ObservableObject { } 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 + configurationHelper.configurations() } + } extension TimerEngine: TimerSchedulerDelegate { diff --git a/Gaze/Services/TimerManager.swift b/Gaze/Services/TimerManager.swift index a4c8df8..c1c774d 100644 --- a/Gaze/Services/TimerManager.swift +++ b/Gaze/Services/TimerManager.swift @@ -38,14 +38,12 @@ class TimerManager: ObservableObject { // Add built-in timers (using unified approach) for timerType in TimerType.allCases { - let config = settingsProvider.timerConfiguration(for: timerType) - if config.enabled { + let intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60 + if settingsProvider.isTimerEnabled(for: timerType) { let identifier = TimerIdentifier.builtIn(timerType) - newStates[identifier] = TimerState( + newStates[identifier] = TimerStateBuilder.make( identifier: identifier, - intervalSeconds: config.intervalSeconds, - isPaused: false, - isActive: true + intervalSeconds: intervalSeconds ) } } @@ -53,11 +51,9 @@ class TimerManager: ObservableObject { // 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( + newStates[identifier] = TimerStateBuilder.make( identifier: identifier, - intervalSeconds: userTimer.intervalMinutes * 60, - isPaused: false, - isActive: true + intervalSeconds: userTimer.intervalMinutes * 60 ) } @@ -85,35 +81,30 @@ class TimerManager: ObservableObject { // Update built-in timers (using unified approach) for timerType in TimerType.allCases { - let config = settingsProvider.timerConfiguration(for: timerType) + let intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60 let identifier = TimerIdentifier.builtIn(timerType) - - if config.enabled { + + if settingsProvider.isTimerEnabled(for: timerType) { if let existingState = timerStates[identifier] { // Timer exists - check if interval changed - if existingState.originalIntervalSeconds != config.intervalSeconds { + if existingState.originalIntervalSeconds != intervalSeconds { // Interval changed - reset with new interval - newStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: config.intervalSeconds, - isPaused: existingState.isPaused, - isActive: true - ) + var updatedState = existingState + updatedState.reset(intervalSeconds: intervalSeconds, keepPaused: true) + newStates[identifier] = updatedState } else { // Interval unchanged - keep existing state newStates[identifier] = existingState } } else { // Timer was just enabled - create new state - newStates[identifier] = TimerState( + newStates[identifier] = TimerStateBuilder.make( identifier: identifier, - intervalSeconds: config.intervalSeconds, - isPaused: false, - isActive: true + intervalSeconds: intervalSeconds ) } } - // If config.enabled is false and timer exists, it will be removed + // If timer is disabled, it will be removed } // Update user timers (using unified approach) @@ -126,23 +117,18 @@ class TimerManager: ObservableObject { // Check if interval changed if existingState.originalIntervalSeconds != newIntervalSeconds { // Interval changed - reset with new interval - newStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: newIntervalSeconds, - isPaused: existingState.isPaused, - isActive: true - ) + var updatedState = existingState + updatedState.reset(intervalSeconds: newIntervalSeconds, keepPaused: true) + newStates[identifier] = updatedState } else { // Interval unchanged - keep existing state newStates[identifier] = existingState } } else { // New timer - create state - newStates[identifier] = TimerState( + newStates[identifier] = TimerStateBuilder.make( identifier: identifier, - intervalSeconds: newIntervalSeconds, - isPaused: false, - isActive: true + intervalSeconds: newIntervalSeconds ) } } @@ -158,7 +144,7 @@ class TimerManager: ObservableObject { guard !state.isPaused else { continue } guard state.isActive else { continue } - if state.targetDate < timeProvider.now() - 3.0 { + if state.targetDate(using: timeProvider) < timeProvider.now() - 3.0 { // Timer has expired but with some grace period continue } @@ -211,20 +197,16 @@ class TimerManager: ObservableObject { // Unified approach to get interval - no more separate handling for user timers let intervalSeconds = getTimerInterval(for: identifier) - timerStates[identifier] = TimerState( - identifier: identifier, - intervalSeconds: intervalSeconds, - isPaused: state.isPaused, - isActive: state.isActive - ) + var updatedState = state + updatedState.reset(intervalSeconds: intervalSeconds, keepPaused: true) + timerStates[identifier] = updatedState } /// Unified way to get interval for any timer type private func getTimerInterval(for identifier: TimerIdentifier) -> Int { switch identifier { case .builtIn(let type): - let config = settingsProvider.timerConfiguration(for: type) - return config.intervalSeconds + return settingsProvider.timerIntervalMinutes(for: type) * 60 case .user(let id): guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else { return 0 @@ -234,8 +216,7 @@ class TimerManager: ObservableObject { } func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval { - guard let state = timerStates[identifier] else { return 0 } - return TimeInterval(state.remainingSeconds) + timerStates[identifier]?.remainingDuration ?? 0 } func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String { @@ -245,4 +226,4 @@ class TimerManager: ObservableObject { func isTimerPaused(_ identifier: TimerIdentifier) -> Bool { return timerStates[identifier]?.isPaused ?? true } -} \ No newline at end of file +} diff --git a/Gaze/Views/Components/SliderSection.swift b/Gaze/Views/Components/SliderSection.swift index feed548..f886cdb 100644 --- a/Gaze/Views/Components/SliderSection.swift +++ b/Gaze/Views/Components/SliderSection.swift @@ -16,7 +16,7 @@ struct SliderSection: View { previewFunc: @escaping () -> Void ) { self._intervalSettings = intervalSettings - self._countdownSettings = countdownSettings ?? .constant(RangeChoice(val: nil, range: nil)) + self._countdownSettings = countdownSettings ?? .constant(RangeChoice(value: nil, range: nil)) self._enabled = enabled self.type = type self.previewFunc = previewFunc @@ -27,10 +27,10 @@ struct SliderSection: View { return "\(type) reminders are currently disabled." } if countdownSettings.isNil && !intervalSettings.isNil { - return "You will be reminded every \(intervalSettings.val ?? 0) minutes" + return "You will be reminded every \(intervalSettings.value ?? 0) minutes" } return - "You will be \(countdownSettings.isNil ? "subtly" : "") reminded every \(intervalSettings.val ?? 0) minutes for \(countdownSettings.val ?? 0) seconds" + "You will be \(countdownSettings.isNil ? "subtly" : "") reminded every \(intervalSettings.value ?? 0) minutes for \(countdownSettings.value ?? 0) seconds" } var body: some View { @@ -46,15 +46,15 @@ struct SliderSection: View { HStack { Slider( value: Binding( - get: { Double(intervalSettings.val ?? 0) }, - set: { intervalSettings.val = Int($0) } + get: { Double(intervalSettings.value ?? 0) }, + set: { intervalSettings.value = Int($0) } ), in: Double( intervalSettings.range?.bounds.lowerBound ?? 0)...Double( intervalSettings.range?.bounds.upperBound ?? 100), step: 5.0) - Text("\(intervalSettings.val ?? 0) min") + Text("\(intervalSettings.value ?? 0) min") .frame(width: 60, alignment: .trailing) .monospacedDigit() } @@ -66,14 +66,14 @@ struct SliderSection: View { HStack { Slider( value: Binding( - get: { Double(countdownSettings.val ?? 0) }, - set: { countdownSettings.val = Int($0) } + get: { Double(countdownSettings.value ?? 0) }, + set: { countdownSettings.value = Int($0) } ), in: Double( range.bounds.lowerBound)...Double(range.bounds.upperBound), step: 5.0) - Text("\(countdownSettings.val ?? 0) sec") + Text("\(countdownSettings.value ?? 0) sec") .frame(width: 60, alignment: .trailing) .monospacedDigit() } diff --git a/Gaze/Views/Containers/AdditionalModifiersView.swift b/Gaze/Views/Containers/AdditionalModifiersView.swift index fb978d2..6075251 100644 --- a/Gaze/Views/Containers/AdditionalModifiersView.swift +++ b/Gaze/Views/Containers/AdditionalModifiersView.swift @@ -254,10 +254,23 @@ struct AdditionalModifiersView: View { } } Spacer() - Toggle("", isOn: $settingsManager.settings.enforcementMode) - .labelsHidden() - .disabled(!cameraService.hasCameraHardware) - .controlSize(isCompact ? .small : .regular) + Toggle("", isOn: Binding( + get: { + settingsManager.isTimerEnabled(for: .lookAway) || + settingsManager.isTimerEnabled(for: .blink) || + settingsManager.isTimerEnabled(for: .posture) + }, + set: { newValue in + if newValue { + Task { @MainActor in + try await cameraService.requestCameraAccess() + } + } + } + )) + .labelsHidden() + .disabled(!cameraService.hasCameraHardware) + .controlSize(isCompact ? .small : .regular) } .padding(isCompact ? 10 : 16) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) diff --git a/Gaze/Views/Setup/BlinkSetupView.swift b/Gaze/Views/Setup/BlinkSetupView.swift index b566fb9..7f17171 100644 --- a/Gaze/Views/Setup/BlinkSetupView.swift +++ b/Gaze/Views/Setup/BlinkSetupView.swift @@ -44,11 +44,11 @@ struct BlinkSetupView: View { VStack(alignment: .leading, spacing: 20) { Toggle( - "Enable Blink Reminders", isOn: $settingsManager.settings.blinkTimer.enabled + "Enable Blink Reminders", isOn: $settingsManager.settings.blinkEnabled ) .font(.headline) - if settingsManager.settings.blinkTimer.enabled { + if settingsManager.settings.blinkEnabled { VStack(alignment: .leading, spacing: 12) { Text("Remind me every:") .font(.subheadline) @@ -58,13 +58,10 @@ struct BlinkSetupView: View { Slider( value: Binding( get: { - Double( - settingsManager.settings.blinkTimer.intervalSeconds - / 60) + Double(settingsManager.settings.blinkIntervalMinutes) }, set: { - settingsManager.settings.blinkTimer.intervalSeconds = - Int($0) * 60 + settingsManager.settings.blinkIntervalMinutes = Int($0) } ), in: 1...20, @@ -72,7 +69,7 @@ struct BlinkSetupView: View { ) Text( - "\(settingsManager.settings.blinkTimer.intervalSeconds / 60) min" + "\(settingsManager.settings.blinkIntervalMinutes) min" ) .frame(width: 60, alignment: .trailing) .monospacedDigit() @@ -83,9 +80,9 @@ struct BlinkSetupView: View { .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - if settingsManager.settings.blinkTimer.enabled { + if settingsManager.settings.blinkEnabled { Text( - "You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink" + "You will be subtly reminded every \(settingsManager.settings.blinkIntervalMinutes) minutes to blink" ) .font(.subheadline) .foregroundStyle(.secondary) @@ -109,8 +106,7 @@ struct BlinkSetupView: View { } .buttonStyle(.plain) .glassEffectIfAvailable( - GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10) - ) + GlassStyle.regular.tint(.green).interactive(), in: .rect(cornerRadius: 10)) } Spacer() @@ -129,6 +125,6 @@ struct BlinkSetupView: View { } } -#Preview("Blink Setup") { +#Preview("Blink Setup View") { BlinkSetupView(settingsManager: SettingsManager.shared) } diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index a2fd0e6..6f14631 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -61,7 +61,9 @@ struct EnforceModeSetupView: View { "", isOn: Binding( get: { - settingsManager.settings.enforcementMode + settingsManager.isTimerEnabled(for: .lookAway) || + settingsManager.isTimerEnabled(for: .blink) || + settingsManager.isTimerEnabled(for: .posture) }, set: { newValue in print("🎛️ Toggle changed to: \(newValue)") @@ -69,7 +71,6 @@ struct EnforceModeSetupView: View { print("⚠️ Already processing toggle") return } - settingsManager.settings.enforcementMode = newValue handleEnforceModeToggle(enabled: newValue) } ) @@ -390,7 +391,6 @@ struct EnforceModeSetupView: View { if enabled { guard cameraHardwareAvailable else { print("⚠️ Cannot enable enforce mode - no camera hardware") - settingsManager.settings.enforcementMode = false return } print("🎛️ Enabling enforce mode...") @@ -399,7 +399,6 @@ struct EnforceModeSetupView: View { if !enforceModeService.isEnforceModeEnabled { print("⚠️ Failed to activate, reverting toggle") - settingsManager.settings.enforcementMode = false } } else { print("🎛️ Disabling enforce mode...") diff --git a/Gaze/Views/Setup/LookAwaySetupView.swift b/Gaze/Views/Setup/LookAwaySetupView.swift index 456b9e3..1a0d6c4 100644 --- a/Gaze/Views/Setup/LookAwaySetupView.swift +++ b/Gaze/Views/Setup/LookAwaySetupView.swift @@ -30,29 +30,18 @@ struct LookAwaySetupView: View { intervalSettings: Binding( get: { RangeChoice( - val: settingsManager.settings.lookAwayTimer.intervalSeconds / 60, + value: settingsManager.settings.lookAwayIntervalMinutes, range: Range(bounds: 5...60, step: 5) ) }, set: { newValue in - settingsManager.settings.lookAwayTimer.intervalSeconds = - (newValue.val ?? 20) * 60 + settingsManager.settings.lookAwayIntervalMinutes = newValue.value ?? 30 } ), - countdownSettings: Binding( - get: { - RangeChoice( - val: settingsManager.settings.lookAwayCountdownSeconds, - range: Range(bounds: 5...60, step: 5) - ) - }, - set: { newValue in - settingsManager.settings.lookAwayCountdownSeconds = newValue.val ?? 20 - } - ), - enabled: $settingsManager.settings.lookAwayTimer.enabled, - type: "Look away", - previewFunc: showPreviewWindow + countdownSettings: nil, + enabled: $settingsManager.settings.lookAwayEnabled, + type: "Look Away", + previewFunc: previewLookAway ) } @@ -63,11 +52,12 @@ struct LookAwaySetupView: View { .background(.clear) } - private func showPreviewWindow() { + private func previewLookAway() { guard let screen = NSScreen.main else { return } - let countdownSeconds = settingsManager.settings.lookAwayCountdownSeconds + let sizePercentage = settingsManager.settings.subtleReminderSize.percentage + let lookAwayIntervalMinutes = settingsManager.settings.lookAwayIntervalMinutes PreviewWindowHelper.showPreview(on: screen) { dismiss in - LookAwayReminderView(countdownSeconds: countdownSeconds, onDismiss: dismiss) + LookAwayReminderView(countdownSeconds: lookAwayIntervalMinutes * 60, onDismiss: dismiss) } } } diff --git a/Gaze/Views/Setup/PostureSetupView.swift b/Gaze/Views/Setup/PostureSetupView.swift index 7ae0622..f71f11b 100644 --- a/Gaze/Views/Setup/PostureSetupView.swift +++ b/Gaze/Views/Setup/PostureSetupView.swift @@ -46,17 +46,16 @@ struct PostureSetupView: View { intervalSettings: Binding( get: { RangeChoice( - val: settingsManager.settings.postureTimer.intervalSeconds / 60, + value: settingsManager.settings.postureIntervalMinutes, range: Range(bounds: 5...60, step: 5) ) }, set: { newValue in - settingsManager.settings.postureTimer.intervalSeconds = - (newValue.val ?? 30) * 60 + settingsManager.settings.postureIntervalMinutes = newValue.value ?? 30 } ), countdownSettings: nil, - enabled: $settingsManager.settings.postureTimer.enabled, + enabled: $settingsManager.settings.postureEnabled, type: "Posture", previewFunc: showPreviewWindow ) diff --git a/GazeTests/AppDelegateTestabilityTests.swift b/GazeTests/AppDelegateTestabilityTests.swift index 09354cc..410c65c 100644 --- a/GazeTests/AppDelegateTestabilityTests.swift +++ b/GazeTests/AppDelegateTestabilityTests.swift @@ -53,12 +53,12 @@ final class AppDelegateTestabilityTests: XCTestCase { let appDelegate = testEnv.createAppDelegate() // Change a setting - testEnv.settingsManager.settings.lookAwayTimer.enabled = false + testEnv.settingsManager.settings.lookAwayEnabled = false try await Task.sleep(for: .milliseconds(50)) // Verify the change propagated - XCTAssertFalse(testEnv.settingsManager.settings.lookAwayTimer.enabled) + XCTAssertFalse(testEnv.settingsManager.settings.lookAwayEnabled) } func testOpenSettingsUsesWindowManager() { diff --git a/GazeTests/OnboardingNavigationTests.swift b/GazeTests/OnboardingNavigationTests.swift index e4eedf0..0fa69d9 100644 --- a/GazeTests/OnboardingNavigationTests.swift +++ b/GazeTests/OnboardingNavigationTests.swift @@ -79,30 +79,22 @@ final class OnboardingNavigationTests: XCTestCase { func testSettingsPersistDuringNavigation() { // Configure lookaway timer - var config = testEnv.settingsManager.settings.lookAwayTimer - config.enabled = true - config.intervalSeconds = 1200 - testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + testEnv.settingsManager.settings.lookAwayEnabled = true + testEnv.settingsManager.settings.lookAwayIntervalMinutes = 20 // Verify settings persisted - let retrieved = testEnv.settingsManager.timerConfiguration(for: .lookAway) - XCTAssertTrue(retrieved.enabled) - XCTAssertEqual(retrieved.intervalSeconds, 1200) + XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 20) // Configure blink timer - var blinkConfig = testEnv.settingsManager.settings.blinkTimer - blinkConfig.enabled = false - blinkConfig.intervalSeconds = 300 - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + testEnv.settingsManager.settings.blinkEnabled = false + testEnv.settingsManager.settings.blinkIntervalMinutes = 5 // Verify both settings persist - let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway) - let blink = testEnv.settingsManager.timerConfiguration(for: .blink) - - XCTAssertTrue(lookAway.enabled) - XCTAssertEqual(lookAway.intervalSeconds, 1200) - XCTAssertFalse(blink.enabled) - XCTAssertEqual(blink.intervalSeconds, 300) + XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 20) + XCTAssertFalse(testEnv.settingsManager.settings.blinkEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 5) } func testOnboardingCompletion() { @@ -118,45 +110,26 @@ final class OnboardingNavigationTests: XCTestCase { func testAllTimersConfiguredDuringOnboarding() { // Configure all three built-in timers - var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer - lookAwayConfig.enabled = true - lookAwayConfig.intervalSeconds = 1200 - testEnv.settingsManager.updateTimerConfiguration( - for: .lookAway, configuration: lookAwayConfig) - - var blinkConfig = testEnv.settingsManager.settings.blinkTimer - blinkConfig.enabled = true - blinkConfig.intervalSeconds = 300 - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) - - var postureConfig = testEnv.settingsManager.settings.postureTimer - postureConfig.enabled = true - postureConfig.intervalSeconds = 1800 - testEnv.settingsManager.updateTimerConfiguration( - for: .posture, configuration: postureConfig) + testEnv.settingsManager.settings.lookAwayEnabled = true + testEnv.settingsManager.settings.lookAwayIntervalMinutes = 20 + testEnv.settingsManager.settings.blinkEnabled = true + testEnv.settingsManager.settings.blinkIntervalMinutes = 5 + testEnv.settingsManager.settings.postureEnabled = true + testEnv.settingsManager.settings.postureIntervalMinutes = 30 // Verify all configurations - let allConfigs = testEnv.settingsManager.allTimerConfigurations() - - XCTAssertEqual(allConfigs[.lookAway]?.intervalSeconds, 1200) - XCTAssertEqual(allConfigs[.blink]?.intervalSeconds, 300) - XCTAssertEqual(allConfigs[.posture]?.intervalSeconds, 1800) - - XCTAssertTrue(allConfigs[.lookAway]?.enabled ?? false) - XCTAssertTrue(allConfigs[.blink]?.enabled ?? false) - XCTAssertTrue(allConfigs[.posture]?.enabled ?? false) + XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 20) + XCTAssertTrue(testEnv.settingsManager.settings.blinkEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 5) + XCTAssertTrue(testEnv.settingsManager.settings.postureEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.postureIntervalMinutes, 30) } func testNavigationWithPartialConfiguration() { // Configure only some timers - var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer - lookAwayConfig.enabled = true - testEnv.settingsManager.updateTimerConfiguration( - for: .lookAway, configuration: lookAwayConfig) - - var blinkConfig = testEnv.settingsManager.settings.blinkTimer - blinkConfig.enabled = false - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + testEnv.settingsManager.settings.lookAwayEnabled = true + testEnv.settingsManager.settings.blinkEnabled = false // Should still be able to complete onboarding testEnv.settingsManager.settings.hasCompletedOnboarding = true @@ -181,23 +154,15 @@ final class OnboardingNavigationTests: XCTestCase { // Page 1: MenuBar Welcome - no configuration needed // Page 2: LookAway Setup - var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer - lookAwayConfig.enabled = true - lookAwayConfig.intervalSeconds = 1200 - testEnv.settingsManager.updateTimerConfiguration( - for: .lookAway, configuration: lookAwayConfig) + testEnv.settingsManager.settings.lookAwayEnabled = true + testEnv.settingsManager.settings.lookAwayIntervalMinutes = 20 // Page 2: Blink Setup - var blinkConfig = testEnv.settingsManager.settings.blinkTimer - blinkConfig.enabled = true - blinkConfig.intervalSeconds = 300 - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + testEnv.settingsManager.settings.blinkEnabled = true + testEnv.settingsManager.settings.blinkIntervalMinutes = 5 // Page 3: Posture Setup - var postureConfig = testEnv.settingsManager.settings.postureTimer - postureConfig.enabled = false // User chooses to disable this one - testEnv.settingsManager.updateTimerConfiguration( - for: .posture, configuration: postureConfig) + testEnv.settingsManager.settings.postureEnabled = false // User chooses to disable this one // Page 4: General Settings testEnv.settingsManager.settings.playSounds = true @@ -209,10 +174,9 @@ final class OnboardingNavigationTests: XCTestCase { // Verify final state XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding) - let finalConfigs = testEnv.settingsManager.allTimerConfigurations() - XCTAssertTrue(finalConfigs[.lookAway]?.enabled ?? false) - XCTAssertTrue(finalConfigs[.blink]?.enabled ?? false) - XCTAssertFalse(finalConfigs[.posture]?.enabled ?? true) + XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled) + XCTAssertTrue(testEnv.settingsManager.settings.blinkEnabled) + XCTAssertFalse(testEnv.settingsManager.settings.postureEnabled) XCTAssertTrue(testEnv.settingsManager.settings.playSounds) XCTAssertFalse(testEnv.settingsManager.settings.launchAtLogin) @@ -220,24 +184,17 @@ final class OnboardingNavigationTests: XCTestCase { func testNavigatingBackPreservesSettings() { // Configure on page 1 - var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer - lookAwayConfig.intervalSeconds = 1500 - testEnv.settingsManager.updateTimerConfiguration( - for: .lookAway, configuration: lookAwayConfig) + testEnv.settingsManager.settings.lookAwayIntervalMinutes = 25 // Move forward to page 2 - var blinkConfig = testEnv.settingsManager.settings.blinkTimer - blinkConfig.intervalSeconds = 250 - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + testEnv.settingsManager.settings.blinkIntervalMinutes = 4 // Navigate back to page 1 // Verify lookaway settings still exist - let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway) - XCTAssertEqual(lookAway.intervalSeconds, 1500) + XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 25) // Navigate forward again to page 2 // Verify blink settings still exist - let blink = testEnv.settingsManager.timerConfiguration(for: .blink) - XCTAssertEqual(blink.intervalSeconds, 250) + XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 4) } } diff --git a/GazeTests/ServiceContainerTests.swift b/GazeTests/ServiceContainerTests.swift index ced627c..723220e 100644 --- a/GazeTests/ServiceContainerTests.swift +++ b/GazeTests/ServiceContainerTests.swift @@ -22,8 +22,8 @@ final class ServiceContainerTests: XCTestCase { let settings = AppSettings.onlyLookAwayEnabled let container = TestServiceContainer(settings: settings) - XCTAssertEqual(container.settingsManager.settings.lookAwayTimer.enabled, true) - XCTAssertEqual(container.settingsManager.settings.blinkTimer.enabled, false) + XCTAssertEqual(container.settingsManager.settings.lookAwayEnabled, true) + XCTAssertEqual(container.settingsManager.settings.blinkEnabled, false) } func testTimerEngineCreation() { diff --git a/GazeTests/Services/SettingsManagerTests.swift b/GazeTests/Services/SettingsManagerTests.swift index 3a6eb84..c6bbe3e 100644 --- a/GazeTests/Services/SettingsManagerTests.swift +++ b/GazeTests/Services/SettingsManagerTests.swift @@ -38,52 +38,42 @@ final class SettingsManagerTests: XCTestCase { func testDefaultSettingsValues() { let defaults = AppSettings.defaults - XCTAssertTrue(defaults.lookAwayTimer.enabled) - XCTAssertFalse(defaults.blinkTimer.enabled) // Blink timer is disabled by default - XCTAssertTrue(defaults.postureTimer.enabled) + XCTAssertTrue(defaults.lookAwayEnabled) + XCTAssertFalse(defaults.blinkEnabled) // Blink timer is disabled by default + XCTAssertTrue(defaults.postureEnabled) XCTAssertFalse(defaults.hasCompletedOnboarding) } // MARK: - Timer Configuration Tests func testGetTimerConfiguration() { - let lookAwayConfig = settingsManager.timerConfiguration(for: .lookAway) - XCTAssertNotNil(lookAwayConfig) - XCTAssertTrue(lookAwayConfig.enabled) + XCTAssertTrue(settingsManager.settings.lookAwayEnabled) } func testUpdateTimerConfiguration() { - var config = settingsManager.timerConfiguration(for: .lookAway) - config.intervalSeconds = 1500 - config.enabled = false + settingsManager.settings.lookAwayEnabled = false + settingsManager.settings.lookAwayIntervalMinutes = 25 - settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) - - let updated = settingsManager.timerConfiguration(for: .lookAway) - XCTAssertEqual(updated.intervalSeconds, 1500) - XCTAssertFalse(updated.enabled) + XCTAssertFalse(settingsManager.settings.lookAwayEnabled) + XCTAssertEqual(settingsManager.settings.lookAwayIntervalMinutes, 25) } func testAllTimerConfigurations() { - let allConfigs = settingsManager.allTimerConfigurations() - - XCTAssertEqual(allConfigs.count, 3) - XCTAssertNotNil(allConfigs[.lookAway]) - XCTAssertNotNil(allConfigs[.blink]) - XCTAssertNotNil(allConfigs[.posture]) + XCTAssertEqual(settingsManager.settings.lookAwayEnabled, true) + XCTAssertEqual(settingsManager.settings.blinkEnabled, false) + XCTAssertEqual(settingsManager.settings.postureEnabled, true) } func testUpdateMultipleTimerConfigurations() { - var lookAway = settingsManager.timerConfiguration(for: .lookAway) - lookAway.intervalSeconds = 1000 - settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAway) + settingsManager.settings.lookAwayEnabled = true + settingsManager.settings.lookAwayIntervalMinutes = 16 + settingsManager.settings.blinkEnabled = true + settingsManager.settings.blinkIntervalMinutes = 4 - var blink = settingsManager.timerConfiguration(for: .blink) - blink.intervalSeconds = 250 - settingsManager.updateTimerConfiguration(for: .blink, configuration: blink) - - XCTAssertEqual(settingsManager.timerConfiguration(for: .lookAway).intervalSeconds, 1000) - XCTAssertEqual(settingsManager.timerConfiguration(for: .blink).intervalSeconds, 250) + XCTAssertTrue(settingsManager.settings.lookAwayEnabled) + XCTAssertEqual(settingsManager.settings.lookAwayIntervalMinutes, 16) + XCTAssertTrue(settingsManager.settings.blinkEnabled) + XCTAssertEqual(settingsManager.settings.blinkIntervalMinutes, 4) } // MARK: - Settings Publisher Tests @@ -138,9 +128,8 @@ final class SettingsManagerTests: XCTestCase { // Modify settings settingsManager.settings.playSounds = false settingsManager.settings.launchAtLogin = true - var config = settingsManager.timerConfiguration(for: .lookAway) - config.intervalSeconds = 5000 - settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + settingsManager.settings.lookAwayEnabled = false + settingsManager.settings.lookAwayIntervalMinutes = 10 // Reset settingsManager.resetToDefaults() @@ -149,6 +138,8 @@ final class SettingsManagerTests: XCTestCase { let defaults = AppSettings.defaults XCTAssertEqual(settingsManager.settings.playSounds, defaults.playSounds) XCTAssertEqual(settingsManager.settings.launchAtLogin, defaults.launchAtLogin) + XCTAssertEqual(settingsManager.settings.lookAwayEnabled, defaults.lookAwayEnabled) + XCTAssertEqual(settingsManager.settings.lookAwayIntervalMinutes, defaults.lookAwayIntervalMinutes) } // MARK: - Onboarding Tests diff --git a/GazeTests/Services/TimerEngineTests.swift b/GazeTests/Services/TimerEngineTests.swift index ec2e55b..c3367e6 100644 --- a/GazeTests/Services/TimerEngineTests.swift +++ b/GazeTests/Services/TimerEngineTests.swift @@ -282,9 +282,9 @@ final class TimerEngineTests: XCTestCase { func testDisabledTimersNotInitialized() { var settings = AppSettings.defaults - settings.lookAwayTimer.enabled = false - settings.blinkTimer.enabled = false - settings.postureTimer.enabled = false + settings.lookAwayEnabled = false + settings.blinkEnabled = false + settings.postureEnabled = false let settingsManager = EnhancedMockSettingsManager(settings: settings) let engine = TimerEngine(settingsManager: settingsManager) @@ -296,9 +296,9 @@ final class TimerEngineTests: XCTestCase { func testPartiallyEnabledTimers() { var settings = AppSettings.defaults - settings.lookAwayTimer.enabled = true - settings.blinkTimer.enabled = false - settings.postureTimer.enabled = false + settings.lookAwayEnabled = true + settings.blinkEnabled = false + settings.postureEnabled = false let settingsManager = EnhancedMockSettingsManager(settings: settings) let engine = TimerEngine(settingsManager: settingsManager) diff --git a/GazeTests/TestHelpers.swift b/GazeTests/TestHelpers.swift index 8e85af5..d4353ef 100644 --- a/GazeTests/TestHelpers.swift +++ b/GazeTests/TestHelpers.swift @@ -28,11 +28,19 @@ final class EnhancedMockSettingsManager: SettingsProviding { } @ObservationIgnored - private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = + private let intervalKeyPaths: [TimerType: WritableKeyPath] = [ - .lookAway: \.lookAwayTimer, - .blink: \.blinkTimer, - .posture: \.postureTimer, + .lookAway: \.lookAwayIntervalMinutes, + .blink: \.blinkIntervalMinutes, + .posture: \.postureIntervalMinutes, + ] + + @ObservationIgnored + private let enabledKeyPaths: [TimerType: WritableKeyPath] = + [ + .lookAway: \.lookAwayEnabled, + .blink: \.blinkEnabled, + .posture: \.postureEnabled, ] // Track method calls for verification @@ -50,27 +58,42 @@ final class EnhancedMockSettingsManager: SettingsProviding { self._settingsSubject = CurrentValueSubject(settings) } - func timerConfiguration(for type: TimerType) -> TimerConfiguration { - guard let keyPath = timerConfigKeyPaths[type] else { + func timerIntervalMinutes(for type: TimerType) -> Int { + guard let keyPath = intervalKeyPaths[type] else { preconditionFailure("Unknown timer type: \(type)") } return settings[keyPath: keyPath] } - func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { - guard let keyPath = timerConfigKeyPaths[type] else { + func isTimerEnabled(for type: TimerType) -> Bool { + guard let keyPath = enabledKeyPaths[type] else { preconditionFailure("Unknown timer type: \(type)") } - settings[keyPath: keyPath] = configuration + return settings[keyPath: keyPath] + } + + func updateTimerInterval(for type: TimerType, minutes: Int) { + guard let keyPath = intervalKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + settings[keyPath: keyPath] = minutes _settingsSubject.send(settings) } - func allTimerConfigurations() -> [TimerType: TimerConfiguration] { - var configs: [TimerType: TimerConfiguration] = [:] - for (type, keyPath) in timerConfigKeyPaths { - configs[type] = settings[keyPath: keyPath] + func updateTimerEnabled(for type: TimerType, enabled: Bool) { + guard let keyPath = enabledKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") } - return configs + settings[keyPath: keyPath] = enabled + _settingsSubject.send(settings) + } + + func allTimerSettings() -> [TimerType: (enabled: Bool, intervalMinutes: Int)] { + var settingsMap: [TimerType: (enabled: Bool, intervalMinutes: Int)] = [:] + for (type, enabledKey) in enabledKeyPaths { + settingsMap[type] = (enabled: settings[keyPath: enabledKey], intervalMinutes: settings[keyPath: intervalKeyPaths[type]!]) + } + return settingsMap } func save() { @@ -155,27 +178,27 @@ extension AppSettings { /// Settings with all timers disabled static var allTimersDisabled: AppSettings { var settings = AppSettings.defaults - settings.lookAwayTimer.enabled = false - settings.blinkTimer.enabled = false - settings.postureTimer.enabled = false + settings.lookAwayEnabled = false + settings.blinkEnabled = false + settings.postureEnabled = false return settings } /// Settings with only lookAway timer enabled static var onlyLookAwayEnabled: AppSettings { var settings = AppSettings.defaults - settings.lookAwayTimer.enabled = true - settings.blinkTimer.enabled = false - settings.postureTimer.enabled = false + settings.lookAwayEnabled = true + settings.blinkEnabled = false + settings.postureEnabled = false return settings } /// Settings with short intervals for testing static var shortIntervals: AppSettings { var settings = AppSettings.defaults - settings.lookAwayTimer.intervalSeconds = 5 - settings.blinkTimer.intervalSeconds = 3 - settings.postureTimer.intervalSeconds = 7 + settings.lookAwayIntervalMinutes = 5 + settings.blinkIntervalMinutes = 3 + settings.postureIntervalMinutes = 7 return settings } diff --git a/GazeTests/TimerEngineTestabilityTests.swift b/GazeTests/TimerEngineTestabilityTests.swift index b13cf2b..e2b09bf 100644 --- a/GazeTests/TimerEngineTestabilityTests.swift +++ b/GazeTests/TimerEngineTestabilityTests.swift @@ -40,9 +40,9 @@ final class TimerEngineTestabilityTests: XCTestCase { func testTimerEngineUsesInjectedSettings() { var settings = AppSettings.defaults - settings.lookAwayTimer.enabled = true - settings.blinkTimer.enabled = false - settings.postureTimer.enabled = false + settings.lookAwayEnabled = true + settings.blinkEnabled = false + settings.postureEnabled = false testEnv.settingsManager.settings = settings let timerEngine = testEnv.container.timerEngine diff --git a/GazeTests/Views/BlinkSetupViewTests.swift b/GazeTests/Views/BlinkSetupViewTests.swift index d8d09c6..3d3c310 100644 --- a/GazeTests/Views/BlinkSetupViewTests.swift +++ b/GazeTests/Views/BlinkSetupViewTests.swift @@ -29,40 +29,41 @@ final class BlinkSetupViewTests: XCTestCase { } func testBlinkTimerConfigurationChanges() { - let initial = testEnv.settingsManager.timerConfiguration(for: .blink) + XCTAssertFalse(testEnv.settingsManager.settings.blinkEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 7) - var modified = initial - modified.enabled = true - modified.intervalSeconds = 300 - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: modified) + testEnv.settingsManager.settings.blinkEnabled = true + testEnv.settingsManager.settings.blinkIntervalMinutes = 5 - let updated = testEnv.settingsManager.timerConfiguration(for: .blink) - XCTAssertTrue(updated.enabled) - XCTAssertEqual(updated.intervalSeconds, 300) + XCTAssertTrue(testEnv.settingsManager.settings.blinkEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 5) } func testBlinkTimerEnableDisable() { - var config = testEnv.settingsManager.timerConfiguration(for: .blink) + var config = testEnv.settingsManager.settings - config.enabled = true - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) - XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .blink).enabled) + config.blinkEnabled = true + config.blinkIntervalMinutes = 4 + testEnv.settingsManager.settings = config + XCTAssertTrue(testEnv.settingsManager.settings.blinkEnabled) - config.enabled = false - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) - XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .blink).enabled) + config.blinkEnabled = false + config.blinkIntervalMinutes = 3 + testEnv.settingsManager.settings = config + XCTAssertFalse(testEnv.settingsManager.settings.blinkEnabled) } func testBlinkIntervalValidation() { - var config = testEnv.settingsManager.timerConfiguration(for: .blink) + var config = testEnv.settingsManager.settings - let intervals = [180, 240, 300, 360, 600] - for interval in intervals { - config.intervalSeconds = interval - testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) + let intervals = [3, 4, 5, 6, 10] + for minutes in intervals { + config.blinkEnabled = true + config.blinkIntervalMinutes = minutes + testEnv.settingsManager.settings = config - let retrieved = testEnv.settingsManager.timerConfiguration(for: .blink) - XCTAssertEqual(retrieved.intervalSeconds, interval) + let retrieved = testEnv.settingsManager.settings + XCTAssertEqual(retrieved.blinkIntervalMinutes, minutes) } } diff --git a/GazeTests/Views/LookAwaySetupViewTests.swift b/GazeTests/Views/LookAwaySetupViewTests.swift index f1e4ff2..3ef6c27 100644 --- a/GazeTests/Views/LookAwaySetupViewTests.swift +++ b/GazeTests/Views/LookAwaySetupViewTests.swift @@ -30,45 +30,46 @@ final class LookAwaySetupViewTests: XCTestCase { func testLookAwayTimerConfigurationChanges() { // Start with default - let initial = testEnv.settingsManager.timerConfiguration(for: .lookAway) + XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 20) // Modify configuration - var modified = initial - modified.enabled = true - modified.intervalSeconds = 1500 - testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: modified) + testEnv.settingsManager.settings.lookAwayEnabled = true + testEnv.settingsManager.settings.lookAwayIntervalMinutes = 25 // Verify changes - let updated = testEnv.settingsManager.timerConfiguration(for: .lookAway) - XCTAssertTrue(updated.enabled) - XCTAssertEqual(updated.intervalSeconds, 1500) + XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 25) } func testLookAwayTimerEnableDisable() { - var config = testEnv.settingsManager.timerConfiguration(for: .lookAway) + var config = testEnv.settingsManager.settings // Enable - config.enabled = true - testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) - XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .lookAway).enabled) + config.lookAwayEnabled = true + config.lookAwayIntervalMinutes = 15 + testEnv.settingsManager.settings = config + XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled) // Disable - config.enabled = false - testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) - XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .lookAway).enabled) + config.lookAwayEnabled = false + config.lookAwayIntervalMinutes = 10 + testEnv.settingsManager.settings = config + XCTAssertFalse(testEnv.settingsManager.settings.lookAwayEnabled) } func testLookAwayIntervalValidation() { - var config = testEnv.settingsManager.timerConfiguration(for: .lookAway) + var config = testEnv.settingsManager.settings - // Test various intervals - let intervals = [300, 600, 1200, 1800, 3600] - for interval in intervals { - config.intervalSeconds = interval - testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + // Test various intervals (in minutes) + let intervals = [5, 10, 20, 30, 60] + for minutes in intervals { + config.lookAwayEnabled = true + config.lookAwayIntervalMinutes = minutes + testEnv.settingsManager.settings = config - let retrieved = testEnv.settingsManager.timerConfiguration(for: .lookAway) - XCTAssertEqual(retrieved.intervalSeconds, interval) + let retrieved = testEnv.settingsManager.settings + XCTAssertEqual(retrieved.lookAwayIntervalMinutes, minutes) } } diff --git a/GazeTests/Views/PostureSetupViewTests.swift b/GazeTests/Views/PostureSetupViewTests.swift index 1d48cd0..90b4b21 100644 --- a/GazeTests/Views/PostureSetupViewTests.swift +++ b/GazeTests/Views/PostureSetupViewTests.swift @@ -29,40 +29,47 @@ final class PostureSetupViewTests: XCTestCase { } func testPostureTimerConfigurationChanges() { - let initial = testEnv.settingsManager.timerConfiguration(for: .posture) + // Start with default + XCTAssertTrue(testEnv.settingsManager.settings.postureEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.postureIntervalMinutes, 30) - var modified = initial - modified.enabled = true - modified.intervalSeconds = 1800 - testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: modified) + // Modify configuration + testEnv.settingsManager.settings.postureEnabled = true + testEnv.settingsManager.settings.postureIntervalMinutes = 45 - let updated = testEnv.settingsManager.timerConfiguration(for: .posture) - XCTAssertTrue(updated.enabled) - XCTAssertEqual(updated.intervalSeconds, 1800) + // Verify changes + XCTAssertTrue(testEnv.settingsManager.settings.postureEnabled) + XCTAssertEqual(testEnv.settingsManager.settings.postureIntervalMinutes, 45) } func testPostureTimerEnableDisable() { - var config = testEnv.settingsManager.timerConfiguration(for: .posture) + var config = testEnv.settingsManager.settings - config.enabled = true - testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) - XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .posture).enabled) + // Enable + config.postureEnabled = true + config.postureIntervalMinutes = 25 + testEnv.settingsManager.settings = config + XCTAssertTrue(testEnv.settingsManager.settings.postureEnabled) - config.enabled = false - testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) - XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .posture).enabled) + // Disable + config.postureEnabled = false + config.postureIntervalMinutes = 20 + testEnv.settingsManager.settings = config + XCTAssertFalse(testEnv.settingsManager.settings.postureEnabled) } func testPostureIntervalValidation() { - var config = testEnv.settingsManager.timerConfiguration(for: .posture) + var config = testEnv.settingsManager.settings - let intervals = [900, 1200, 1800, 2400, 3600] - for interval in intervals { - config.intervalSeconds = interval - testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) + // Test various intervals (in minutes) + let intervals = [15, 20, 30, 45, 60] + for minutes in intervals { + config.postureEnabled = true + config.postureIntervalMinutes = minutes + testEnv.settingsManager.settings = config - let retrieved = testEnv.settingsManager.timerConfiguration(for: .posture) - XCTAssertEqual(retrieved.intervalSeconds, interval) + let retrieved = testEnv.settingsManager.settings + XCTAssertEqual(retrieved.postureIntervalMinutes, minutes) } }