oof
This commit is contained in:
@@ -1,22 +1,18 @@
|
||||
struct Range: Codable {
|
||||
var bounds: ClosedRange<Int>
|
||||
var step: Int
|
||||
struct Range: Codable, Equatable {
|
||||
let bounds: ClosedRange<Int>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Reminder Size
|
||||
|
||||
enum ReminderSize: String, Codable, CaseIterable, Sendable {
|
||||
case small
|
||||
case medium
|
||||
@@ -32,11 +30,12 @@ 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]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,29 +76,41 @@ struct GazeSample: Codable {
|
||||
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 {
|
||||
@@ -121,10 +133,14 @@ struct GazeThresholds: Codable {
|
||||
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:
|
||||
@@ -154,6 +170,14 @@ 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 = [:]
|
||||
@@ -193,152 +217,20 @@ struct CalibrationData: Codable {
|
||||
}
|
||||
|
||||
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
|
||||
self.computedThresholds = thresholdCalculator.calculate(using: self)
|
||||
logStepData()
|
||||
}
|
||||
|
||||
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
|
||||
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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,4 +253,29 @@ class CalibrationState: @unchecked Sendable {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
158
Gaze/Models/CalibrationThresholdCalculator.swift
Normal file
158
Gaze/Models/CalibrationThresholdCalculator.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
39
Gaze/Models/DefaultSettingsBuilder.swift
Normal file
39
Gaze/Models/DefaultSettingsBuilder.swift
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,16 +13,55 @@ struct TimerState: Equatable, Hashable {
|
||||
var isPaused: Bool
|
||||
var pauseReasons: Set<PauseReason>
|
||||
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<PauseReason> = [],
|
||||
isActive: Bool = true,
|
||||
lastResetDate: Date = Date()
|
||||
) -> TimerState {
|
||||
TimerState(
|
||||
identifier: identifier,
|
||||
remainingSeconds: intervalSeconds,
|
||||
isPaused: isPaused,
|
||||
pauseReasons: pauseReasons,
|
||||
isActive: isActive,
|
||||
originalIntervalSeconds: intervalSeconds,
|
||||
lastResetDate: lastResetDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppSettings, Never> { get }
|
||||
|
||||
func timerConfiguration(for type: TimerType) -> TimerConfiguration
|
||||
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration)
|
||||
func allTimerConfigurations() -> [TimerType: TimerConfiguration]
|
||||
func save()
|
||||
func saveImmediately()
|
||||
func load()
|
||||
|
||||
84
Gaze/Services/CalibrationFlowController.swift
Normal file
84
Gaze/Services/CalibrationFlowController.swift
Normal file
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
// Created by Mike Freno on 1/15/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
class CalibrationManager: ObservableObject {
|
||||
@@ -26,7 +26,6 @@ class CalibrationManager: ObservableObject {
|
||||
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,
|
||||
@@ -39,10 +38,29 @@ class CalibrationManager: ObservableObject {
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
29
Gaze/Services/CalibrationSampleCollector.swift
Normal file
29
Gaze/Services/CalibrationSampleCollector.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -21,7 +21,7 @@ final class EnforcePolicyEvaluator {
|
||||
}
|
||||
|
||||
var isEnforcementEnabled: Bool {
|
||||
settingsProvider.settings.enforcementMode
|
||||
settingsProvider.isTimerEnabled(for: .lookAway)
|
||||
}
|
||||
|
||||
func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
21
Gaze/Services/FullscreenWindowMatcher.swift
Normal file
21
Gaze/Services/FullscreenWindowMatcher.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -31,10 +31,17 @@ final class SettingsManager {
|
||||
private var saveCancellable: AnyCancellable?
|
||||
|
||||
@ObservationIgnored
|
||||
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
|
||||
.lookAway: \.lookAwayTimer,
|
||||
.blink: \.blinkTimer,
|
||||
.posture: \.postureTimer,
|
||||
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, Bool>] = [
|
||||
.lookAway: \.lookAwayEnabled,
|
||||
.blink: \.blinkEnabled,
|
||||
.posture: \.postureEnabled,
|
||||
]
|
||||
|
||||
@ObservationIgnored
|
||||
private let intervalKeyPaths: [TimerType: WritableKeyPath<AppSettings, Int>] = [
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
36
Gaze/Services/TimerConfigurationHelper.swift
Normal file
36
Gaze/Services/TimerConfigurationHelper.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -254,7 +254,20 @@ struct AdditionalModifiersView: View {
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $settingsManager.settings.enforcementMode)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,11 +28,19 @@ final class EnhancedMockSettingsManager: SettingsProviding {
|
||||
}
|
||||
|
||||
@ObservationIgnored
|
||||
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] =
|
||||
private let intervalKeyPaths: [TimerType: WritableKeyPath<AppSettings, Int>] =
|
||||
[
|
||||
.lookAway: \.lookAwayTimer,
|
||||
.blink: \.blinkTimer,
|
||||
.posture: \.postureTimer,
|
||||
.lookAway: \.lookAwayIntervalMinutes,
|
||||
.blink: \.blinkIntervalMinutes,
|
||||
.posture: \.postureIntervalMinutes,
|
||||
]
|
||||
|
||||
@ObservationIgnored
|
||||
private let enabledKeyPaths: [TimerType: WritableKeyPath<AppSettings, Bool>] =
|
||||
[
|
||||
.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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user