This commit is contained in:
Michael Freno
2026-01-29 08:47:23 -05:00
parent b89a6c3ac4
commit a61d73753e
38 changed files with 1020 additions and 783 deletions

View File

@@ -1,22 +1,18 @@
struct Range: Codable { struct Range: Codable, Equatable {
var bounds: ClosedRange<Int> let bounds: ClosedRange<Int>
var step: Int let step: Int
} }
struct RangeChoice: Equatable { struct RangeChoice: Equatable {
var val: Int? var value: Int?
let range: Range? let range: Range?
static func == (lhs: RangeChoice, rhs: RangeChoice) -> Bool { init(value: Int? = nil, range: Range? = nil) {
lhs.val == rhs.val && lhs.range?.bounds.lowerBound == rhs.range?.bounds.lowerBound self.value = value
&& lhs.range?.bounds.upperBound == rhs.range?.bounds.upperBound
}
init(val: Int? = nil, range: Range? = nil) {
self.val = val
self.range = range self.range = range
} }
var isNil: Bool { var isNil: Bool {
return val == nil || range == nil value == nil || range == nil
} }
} }

View File

@@ -7,8 +7,6 @@
import Foundation import Foundation
// MARK: - Reminder Size
enum ReminderSize: String, Codable, CaseIterable, Sendable { enum ReminderSize: String, Codable, CaseIterable, Sendable {
case small case small
case medium case medium
@@ -32,11 +30,12 @@ enum ReminderSize: String, Codable, CaseIterable, Sendable {
} }
struct AppSettings: Codable, Equatable, Hashable, Sendable { struct AppSettings: Codable, Equatable, Hashable, Sendable {
var lookAwayTimer: TimerConfiguration var lookAwayEnabled: Bool
var lookAwayCountdownSeconds: Int var lookAwayIntervalMinutes: Int
var blinkTimer: TimerConfiguration var blinkEnabled: Bool
var postureTimer: TimerConfiguration var blinkIntervalMinutes: Int
var enforcementMode: Bool = false var postureEnabled: Bool
var postureIntervalMinutes: Int
var userTimers: [UserTimer] var userTimers: [UserTimer]
@@ -49,24 +48,25 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
var playSounds: Bool var playSounds: Bool
init( init(
lookAwayTimer: TimerConfiguration = TimerConfiguration( lookAwayEnabled: Bool = DefaultSettingsBuilder.lookAwayEnabled,
enabled: true, intervalSeconds: 20 * 60), lookAwayIntervalMinutes: Int = DefaultSettingsBuilder.lookAwayIntervalMinutes,
lookAwayCountdownSeconds: Int = 20, blinkEnabled: Bool = DefaultSettingsBuilder.blinkEnabled,
blinkTimer: TimerConfiguration = TimerConfiguration( blinkIntervalMinutes: Int = DefaultSettingsBuilder.blinkIntervalMinutes,
enabled: false, intervalSeconds: 7 * 60), postureEnabled: Bool = DefaultSettingsBuilder.postureEnabled,
postureTimer: TimerConfiguration = TimerConfiguration( postureIntervalMinutes: Int = DefaultSettingsBuilder.postureIntervalMinutes,
enabled: true, intervalSeconds: 30 * 60),
userTimers: [UserTimer] = [], userTimers: [UserTimer] = [],
subtleReminderSize: ReminderSize = .medium, subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize,
smartMode: SmartModeSettings = .defaults, smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode,
hasCompletedOnboarding: Bool = false, hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding,
launchAtLogin: Bool = false, launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin,
playSounds: Bool = true playSounds: Bool = DefaultSettingsBuilder.playSounds
) { ) {
self.lookAwayTimer = lookAwayTimer self.lookAwayEnabled = lookAwayEnabled
self.lookAwayCountdownSeconds = lookAwayCountdownSeconds self.lookAwayIntervalMinutes = lookAwayIntervalMinutes
self.blinkTimer = blinkTimer self.blinkEnabled = blinkEnabled
self.postureTimer = postureTimer self.blinkIntervalMinutes = blinkIntervalMinutes
self.postureEnabled = postureEnabled
self.postureIntervalMinutes = postureIntervalMinutes
self.userTimers = userTimers self.userTimers = userTimers
self.subtleReminderSize = subtleReminderSize self.subtleReminderSize = subtleReminderSize
self.smartMode = smartMode self.smartMode = smartMode
@@ -76,17 +76,6 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
} }
static var defaults: AppSettings { static var defaults: AppSettings {
AppSettings( DefaultSettingsBuilder.makeDefaults()
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
)
} }
} }

View File

@@ -76,29 +76,41 @@ struct GazeSample: Codable {
let faceWidthRatio: Double? // For distance scaling (face width / image width) let faceWidthRatio: Double? // For distance scaling (face width / image width)
let timestamp: Date 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.leftRatio = leftRatio
self.rightRatio = rightRatio self.rightRatio = rightRatio
self.leftVerticalRatio = leftVerticalRatio self.leftVerticalRatio = leftVerticalRatio
self.rightVerticalRatio = rightVerticalRatio self.rightVerticalRatio = rightVerticalRatio
self.faceWidthRatio = faceWidthRatio self.faceWidthRatio = faceWidthRatio
// Calculate average horizontal ratio self.averageRatio = GazeSample.average(left: leftRatio, right: rightRatio, fallback: 0.5)
if let left = leftRatio, let right = rightRatio { self.averageVerticalRatio = GazeSample.average(
self.averageRatio = (left + right) / 2.0 left: leftVerticalRatio,
} else { right: rightVerticalRatio,
self.averageRatio = leftRatio ?? rightRatio ?? 0.5 fallback: 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.timestamp = Date() 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 { struct GazeThresholds: Codable {
@@ -121,10 +133,14 @@ struct GazeThresholds: Codable {
let referenceFaceWidth: Double // Average face width during calibration let referenceFaceWidth: Double // Average face width during calibration
var isValid: Bool { var isValid: Bool {
// Just check that we have reasonable values (not NaN or infinite) isFiniteValues([
let values = [minLeftRatio, maxRightRatio, minUpRatio, maxDownRatio, minLeftRatio, maxRightRatio, minUpRatio, maxDownRatio,
screenLeftBound, screenRightBound, screenTopBound, screenBottomBound] screenLeftBound, screenRightBound, screenTopBound, screenBottomBound,
return values.allSatisfy { $0.isFinite } ])
}
private func isFiniteValues(_ values: [Double]) -> Bool {
values.allSatisfy { $0.isFinite }
} }
/// Default thresholds based on video test data: /// Default thresholds based on video test data:
@@ -154,6 +170,14 @@ struct CalibrationData: Codable {
var computedThresholds: GazeThresholds? var computedThresholds: GazeThresholds?
var calibrationDate: Date var calibrationDate: Date
var isComplete: Bool var isComplete: Bool
private let thresholdCalculator = CalibrationThresholdCalculator()
enum CodingKeys: String, CodingKey {
case samples
case computedThresholds
case calibrationDate
case isComplete
}
init() { init() {
self.samples = [:] self.samples = [:]
@@ -193,152 +217,20 @@ struct CalibrationData: Codable {
} }
mutating func calculateThresholds() { mutating func calculateThresholds() {
// Calibration uses actual measured gaze ratios from the user looking at different self.computedThresholds = thresholdCalculator.calculate(using: self)
// screen positions. The face width during calibration serves as a reference for logStepData()
// distance-based normalization during live tracking.
//
// Coordinate system (based on video testing):
// Horizontal: 0.0 = far right, 1.0 = far left
// Vertical: 0.0 = top, 1.0 = bottom
// Center (looking at screen) typically: H 0.29-0.35
// 1. Get center reference point
let centerH = averageRatio(for: .center)
let centerV = averageVerticalRatio(for: .center)
let centerFaceWidth = averageFaceWidth(for: .center)
guard let cH = centerH else {
print("⚠️ No center calibration data, using defaults")
self.computedThresholds = GazeThresholds.defaultThresholds
return
} }
let cV = centerV ?? 0.45 // Default vertical center private func logStepData() {
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
print(" Per-step data:") print(" Per-step data:")
for step in CalibrationStep.allCases { for step in CalibrationStep.allCases {
if let h = averageRatio(for: step) { if let h = averageRatio(for: step) {
let v = averageVerticalRatio(for: step) ?? -1 let v = averageVerticalRatio(for: step) ?? -1
let fw = averageFaceWidth(for: step) ?? -1 let fw = averageFaceWidth(for: step) ?? -1
let count = getSamples(for: step).count 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 } } get { queue.sync { _isComplete } }
set { queue.async(flags: .barrier) { self._isComplete = newValue } } 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
}
}
}
} }

View 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)
}
}

View 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
)
}
}

View File

@@ -13,16 +13,55 @@ struct TimerState: Equatable, Hashable {
var isPaused: Bool var isPaused: Bool
var pauseReasons: Set<PauseReason> var pauseReasons: Set<PauseReason>
var isActive: Bool var isActive: Bool
var targetDate: Date
let originalIntervalSeconds: Int let originalIntervalSeconds: Int
let lastResetDate: Date
init(identifier: TimerIdentifier, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) { func targetDate(using timeProvider: TimeProviding) -> Date {
self.identifier = identifier lastResetDate.addingTimeInterval(Double(originalIntervalSeconds))
self.remainingSeconds = intervalSeconds }
self.isPaused = isPaused
self.pauseReasons = [] var remainingDuration: TimeInterval {
self.isActive = isActive TimeInterval(remainingSeconds)
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds)) }
self.originalIntervalSeconds = intervalSeconds
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
)
} }
} }

View File

@@ -7,13 +7,17 @@ import Combine
import Foundation import Foundation
@MainActor @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 settings: AppSettings { get set }
var settingsPublisher: AnyPublisher<AppSettings, Never> { get } 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 save()
func saveImmediately() func saveImmediately()
func load() func load()

View 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)"
}
}

View File

@@ -5,8 +5,8 @@
// Created by Mike Freno on 1/15/26. // Created by Mike Freno on 1/15/26.
// //
import Foundation
import Combine import Combine
import Foundation
@MainActor @MainActor
class CalibrationManager: ObservableObject { 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 samplesPerStep = 30 // Collect 30 samples per calibration point (~1 second at 30fps)
private let userDefaultsKey = "eyeTrackingCalibration" private let userDefaultsKey = "eyeTrackingCalibration"
// Calibration sequence (9 steps)
private let calibrationSteps: [CalibrationStep] = [ private let calibrationSteps: [CalibrationStep] = [
.center, .center,
.left, .left,
@@ -39,10 +38,29 @@ class CalibrationManager: ObservableObject {
.topRight .topRight
] ]
private let flowController: CalibrationFlowController
private var sampleCollector = CalibrationSampleCollector()
// MARK: - Initialization // MARK: - Initialization
private init() { private init() {
self.flowController = CalibrationFlowController(
samplesPerStep: samplesPerStep,
calibrationSteps: calibrationSteps
)
loadCalibration() 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 // MARK: - Calibration Flow
@@ -50,10 +68,7 @@ class CalibrationManager: ObservableObject {
func startCalibration() { func startCalibration() {
print("🎯 Starting calibration...") print("🎯 Starting calibration...")
isCalibrating = true isCalibrating = true
isCollectingSamples = false flowController.start()
currentStepIndex = 0
currentStep = calibrationSteps[0]
samplesCollected = 0
calibrationData = CalibrationData() calibrationData = CalibrationData()
} }
@@ -61,44 +76,43 @@ class CalibrationManager: ObservableObject {
func resetForNewCalibration() { func resetForNewCalibration() {
print("🔄 Resetting for new calibration...") print("🔄 Resetting for new calibration...")
calibrationData = CalibrationData() calibrationData = CalibrationData()
flowController.start()
} }
func startCollectingSamples() { func startCollectingSamples() {
guard isCalibrating, currentStep != nil else { return } guard isCalibrating else { return }
print("📊 Started collecting samples for step: \(currentStep?.displayName ?? "unknown")") 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 } guard isCalibrating, isCollectingSamples, let step = currentStep else { return }
let sample = GazeSample( sampleCollector.addSample(
to: &calibrationData,
step: step,
leftRatio: leftRatio, leftRatio: leftRatio,
rightRatio: rightRatio, rightRatio: rightRatio,
leftVerticalRatio: leftVertical, leftVertical: leftVertical,
rightVerticalRatio: rightVertical, rightVertical: rightVertical,
faceWidthRatio: faceWidthRatio faceWidthRatio: faceWidthRatio
) )
calibrationData.addSample(sample, for: step)
samplesCollected += 1
// Move to next step when enough samples collected if flowController.markSampleCollected() {
if samplesCollected >= samplesPerStep {
advanceToNextStep() advanceToNextStep()
} }
} }
private func advanceToNextStep() { private func advanceToNextStep() {
isCollectingSamples = false if flowController.advanceToNextStep() {
currentStepIndex += 1
if currentStepIndex < calibrationSteps.count {
// Move to next calibration point
currentStep = calibrationSteps[currentStepIndex]
samplesCollected = 0
print("📍 Calibration step: \(currentStep?.displayName ?? "unknown")") print("📍 Calibration step: \(currentStep?.displayName ?? "unknown")")
} else { } else {
// All steps complete
finishCalibration() finishCalibration()
} }
} }
@@ -122,10 +136,7 @@ class CalibrationManager: ObservableObject {
applyCalibration() applyCalibration()
isCalibrating = false isCalibrating = false
isCollectingSamples = false flowController.stop()
currentStep = nil
currentStepIndex = 0
samplesCollected = 0
print("✓ Calibration saved and applied") print("✓ Calibration saved and applied")
} }
@@ -133,15 +144,10 @@ class CalibrationManager: ObservableObject {
func cancelCalibration() { func cancelCalibration() {
print("❌ Calibration cancelled") print("❌ Calibration cancelled")
isCalibrating = false isCalibrating = false
isCollectingSamples = false flowController.stop()
currentStep = nil
currentStepIndex = 0
samplesCollected = 0
calibrationData = CalibrationData() calibrationData = CalibrationData()
// Reset thread-safe state CalibrationState.shared.reset()
CalibrationState.shared.isComplete = false
CalibrationState.shared.thresholds = nil
} }
// MARK: - Persistence // MARK: - Persistence
@@ -184,9 +190,7 @@ class CalibrationManager: ObservableObject {
UserDefaults.standard.removeObject(forKey: userDefaultsKey) UserDefaults.standard.removeObject(forKey: userDefaultsKey)
calibrationData = CalibrationData() calibrationData = CalibrationData()
// Reset thread-safe state CalibrationState.shared.reset()
CalibrationState.shared.isComplete = false
CalibrationState.shared.thresholds = nil
print("🗑️ Calibration data cleared") print("🗑️ Calibration data cleared")
} }
@@ -215,8 +219,8 @@ class CalibrationManager: ObservableObject {
} }
// Push to thread-safe state for background processing // Push to thread-safe state for background processing
CalibrationState.shared.thresholds = thresholds CalibrationState.shared.setThresholds(thresholds)
CalibrationState.shared.isComplete = true CalibrationState.shared.setComplete(true)
print("✓ Applied calibrated thresholds:") print("✓ Applied calibrated thresholds:")
print(" Looking left: ≥\(String(format: "%.3f", thresholds.minLeftRatio))") print(" Looking left: ≥\(String(format: "%.3f", thresholds.minLeftRatio))")
@@ -251,13 +255,10 @@ class CalibrationManager: ObservableObject {
// MARK: - Progress // MARK: - Progress
var progress: Double { var progress: Double {
let totalSteps = calibrationSteps.count flowController.progress
let completedSteps = currentStepIndex
let currentProgress = Double(samplesCollected) / Double(samplesPerStep)
return (Double(completedSteps) + currentProgress) / Double(totalSteps)
} }
var progressText: String { var progressText: String {
"\(currentStepIndex + 1) of \(calibrationSteps.count)" flowController.progressText
} }
} }

View 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)
}
}

View File

@@ -69,11 +69,13 @@ final class EnforceCameraController: ObservableObject {
private func startFaceDetectionTimer() { private func startFaceDetectionTimer() {
stopFaceDetectionTimer() 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() self?.checkFaceDetectionTimeout()
} }
} }
} }
private func stopFaceDetectionTimer() { private func stopFaceDetectionTimer() {

View File

@@ -21,7 +21,7 @@ final class EnforcePolicyEvaluator {
} }
var isEnforcementEnabled: Bool { var isEnforcementEnabled: Bool {
settingsProvider.settings.enforcementMode settingsProvider.isTimerEnabled(for: .lookAway)
} }
func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool { func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool {

View File

@@ -42,10 +42,10 @@ class EyeTrackingService: NSObject, ObservableObject {
@Published var debugImageSize: CGSize? @Published var debugImageSize: CGSize?
private let cameraManager = CameraSessionManager() private let cameraManager = CameraSessionManager()
nonisolated(unsafe) private let visionPipeline = VisionPipeline() private let visionPipeline = VisionPipeline()
private let debugAdapter = EyeDebugStateAdapter() private let debugAdapter = EyeDebugStateAdapter()
private let calibrationBridge = CalibrationBridge() private let calibrationBridge = CalibrationBridge()
nonisolated(unsafe) private let gazeDetector: GazeDetector private let gazeDetector: GazeDetector
var previewLayer: AVCaptureVideoPreviewLayer? { var previewLayer: AVCaptureVideoPreviewLayer? {
cameraManager.previewLayer cameraManager.previewLayer

View File

@@ -70,6 +70,7 @@ final class FullscreenDetectionService: ObservableObject {
private var frontmostAppObserver: AnyCancellable? private var frontmostAppObserver: AnyCancellable?
private let permissionManager: ScreenCapturePermissionManaging private let permissionManager: ScreenCapturePermissionManaging
private let environmentProvider: FullscreenEnvironmentProviding private let environmentProvider: FullscreenEnvironmentProviding
private let windowMatcher = FullscreenWindowMatcher()
init( init(
permissionManager: ScreenCapturePermissionManaging, permissionManager: ScreenCapturePermissionManaging,
@@ -111,49 +112,27 @@ final class FullscreenDetectionService: ObservableObject {
let workspace = NSWorkspace.shared let workspace = NSWorkspace.shared
let notificationCenter = workspace.notificationCenter let notificationCenter = workspace.notificationCenter
let spaceObserver = notificationCenter.addObserver( let stateChangeHandler: (Notification) -> Void = { [weak self] _ in
forName: NSWorkspace.activeSpaceDidChangeNotification,
object: workspace,
queue: .main
) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
self?.checkFullscreenState() self?.checkFullscreenState()
} }
} }
observers.append(spaceObserver)
let transitionObserver = notificationCenter.addObserver( let notifications: [(NSNotification.Name, Any?)] = [
forName: NSApplication.didChangeScreenParametersNotification, (NSWorkspace.activeSpaceDidChangeNotification, workspace),
object: nil, (NSApplication.didChangeScreenParametersNotification, nil),
queue: .main (NSWindow.willEnterFullScreenNotification, nil),
) { [weak self] _ in (NSWindow.willExitFullScreenNotification, nil),
Task { @MainActor in ]
self?.checkFullscreenState()
}
}
observers.append(transitionObserver)
let fullscreenObserver = notificationCenter.addObserver( observers = notifications.map { notification, object in
forName: NSWindow.willEnterFullScreenNotification, notificationCenter.addObserver(
object: nil, forName: notification,
queue: .main object: object,
) { [weak self] _ in queue: .main,
Task { @MainActor in using: stateChangeHandler
self?.checkFullscreenState() )
} }
}
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( frontmostAppObserver = NotificationCenter.default.publisher(
for: NSWorkspace.didActivateApplicationNotification, for: NSWorkspace.didActivateApplicationNotification,
@@ -187,7 +166,7 @@ final class FullscreenDetectionService: ObservableObject {
let screens = environmentProvider.screenFrames() let screens = environmentProvider.screenFrames()
for window in windows where window.ownerPID == frontmostPID && window.layer == 0 { 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) setFullscreenState(true)
return return
} }
@@ -196,13 +175,6 @@ final class FullscreenDetectionService: ObservableObject {
setFullscreenState(false) 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) { fileprivate func setFullscreenState(_ isActive: Bool) {
guard isFullscreenActive != isActive else { return } guard isFullscreenActive != isActive else { return }
isFullscreenActive = isActive isFullscreenActive = isActive

View 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
}
}

View File

@@ -38,7 +38,7 @@ class ReminderManager: ObservableObject {
switch type { switch type {
case .lookAway: case .lookAway:
activeReminder = .lookAwayTriggered( activeReminder = .lookAwayTriggered(
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds) countdownSeconds: settingsProvider.timerIntervalMinutes(for: .lookAway) * 60)
case .blink: case .blink:
activeReminder = .blinkTriggered activeReminder = .blinkTriggered
case .posture: case .posture:

View File

@@ -31,10 +31,17 @@ final class SettingsManager {
private var saveCancellable: AnyCancellable? private var saveCancellable: AnyCancellable?
@ObservationIgnored @ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [ private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, Bool>] = [
.lookAway: \.lookAwayTimer, .lookAway: \.lookAwayEnabled,
.blink: \.blinkTimer, .blink: \.blinkEnabled,
.posture: \.postureTimer, .posture: \.postureEnabled,
]
@ObservationIgnored
private let intervalKeyPaths: [TimerType: WritableKeyPath<AppSettings, Int>] = [
.lookAway: \.lookAwayIntervalMinutes,
.blink: \.blinkIntervalMinutes,
.posture: \.postureIntervalMinutes,
] ]
private init() { private init() {
@@ -83,25 +90,37 @@ final class SettingsManager {
settings = .defaults settings = .defaults
} }
func timerConfiguration(for type: TimerType) -> TimerConfiguration { func isTimerEnabled(for type: TimerType) -> Bool {
guard let keyPath = timerConfigKeyPaths[type] else { guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)") preconditionFailure("Unknown timer type: \(type)")
} }
return settings[keyPath: keyPath] return settings[keyPath: keyPath]
} }
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { func updateTimerEnabled(for type: TimerType, enabled: Bool) {
guard let keyPath = timerConfigKeyPaths[type] else { guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)") preconditionFailure("Unknown timer type: \(type)")
} }
settings[keyPath: keyPath] = configuration settings[keyPath: keyPath] = enabled
} }
func allTimerConfigurations() -> [TimerType: TimerConfiguration] { func timerIntervalMinutes(for type: TimerType) -> Int {
var configs: [TimerType: TimerConfiguration] = [:] guard let keyPath = intervalKeyPaths[type] else {
for (type, keyPath) in timerConfigKeyPaths { preconditionFailure("Unknown timer type: \(type)")
configs[type] = settings[keyPath: keyPath] }
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
} }
} }

View File

@@ -26,7 +26,7 @@ final class ReminderTriggerService {
switch type { switch type {
case .lookAway: case .lookAway:
return .lookAwayTriggered( return .lookAwayTriggered(
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds countdownSeconds: settingsProvider.settings.lookAwayIntervalMinutes * 60
) )
case .blink: case .blink:
return .blinkTriggered return .blinkTriggered

View File

@@ -14,85 +14,11 @@ final class TimerStateManager: ObservableObject {
@Published private(set) var activeReminder: ReminderEvent? @Published private(set) var activeReminder: ReminderEvent?
func initializeTimers(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) { func initializeTimers(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) {
var newStates: [TimerIdentifier: TimerState] = [:] timerStates = buildInitialStates(configurations: configurations, userTimers: userTimers)
for (identifier, config) in configurations where config.enabled {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
)
}
for userTimer in userTimers where userTimer.enabled {
let identifier = TimerIdentifier.user(id: userTimer.id)
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: userTimer.intervalMinutes * 60,
isPaused: false,
isActive: true
)
}
timerStates = newStates
} }
func updateConfigurations(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) { func updateConfigurations(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) {
var newStates: [TimerIdentifier: TimerState] = [:] timerStates = buildUpdatedStates(configurations: configurations, userTimers: userTimers)
for (identifier, config) in configurations {
if config.enabled {
if let existingState = timerStates[identifier] {
if existingState.originalIntervalSeconds != config.intervalSeconds {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
newStates[identifier] = existingState
}
} else {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
)
}
}
}
for userTimer in userTimers {
let identifier = TimerIdentifier.user(id: userTimer.id)
let newIntervalSeconds = userTimer.intervalMinutes * 60
if userTimer.enabled {
if let existingState = timerStates[identifier] {
if existingState.originalIntervalSeconds != newIntervalSeconds {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
newStates[identifier] = existingState
}
} else {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: false,
isActive: true
)
}
}
}
timerStates = newStates
} }
func decrementTimer(identifier: TimerIdentifier) -> TimerState? { func decrementTimer(identifier: TimerIdentifier) -> TimerState? {
@@ -137,24 +63,83 @@ final class TimerStateManager: ObservableObject {
} }
func resetTimer(identifier: TimerIdentifier, intervalSeconds: Int) { func resetTimer(identifier: TimerIdentifier, intervalSeconds: Int) {
guard let state = timerStates[identifier] else { return } guard var state = timerStates[identifier] else { return }
timerStates[identifier] = TimerState( state.reset(intervalSeconds: intervalSeconds, keepPaused: true)
identifier: identifier, timerStates[identifier] = state
intervalSeconds: intervalSeconds,
isPaused: state.isPaused,
isActive: state.isActive
)
} }
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval { func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
guard let state = timerStates[identifier] else { return 0 } timerStates[identifier]?.remainingDuration ?? 0
return TimeInterval(state.remainingSeconds)
} }
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool { func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
return timerStates[identifier]?.isPaused ?? true 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() { func clearAll() {
timerStates.removeAll() timerStates.removeAll()
activeReminder = nil activeReminder = nil

View 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
}
}

View File

@@ -18,6 +18,7 @@ class TimerEngine: ObservableObject {
private let stateManager = TimerStateManager() private let stateManager = TimerStateManager()
private let scheduler: TimerScheduler private let scheduler: TimerScheduler
private let reminderService: ReminderTriggerService private let reminderService: ReminderTriggerService
private let configurationHelper: TimerConfigurationHelper
private let smartModeCoordinator = SmartModeCoordinator() private let smartModeCoordinator = SmartModeCoordinator()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -44,6 +45,7 @@ class TimerEngine: ObservableObject {
settingsProvider: settingsManager, settingsProvider: settingsManager,
enforceModeService: enforceModeService ?? EnforceModeService.shared enforceModeService: enforceModeService ?? EnforceModeService.shared
) )
self.configurationHelper = TimerConfigurationHelper(settingsProvider: settingsManager)
Task { @MainActor in Task { @MainActor in
enforceModeService?.setTimerEngine(self) enforceModeService?.setTimerEngine(self)
@@ -94,7 +96,7 @@ class TimerEngine: ObservableObject {
// Initial start - create all timer states // Initial start - create all timer states
stop() stop()
stateManager.initializeTimers( stateManager.initializeTimers(
using: timerConfigurations(), using: configurationHelper.configurations(),
userTimers: settingsProvider.settings.userTimers userTimers: settingsProvider.settings.userTimers
) )
scheduler.start() scheduler.start()
@@ -108,7 +110,7 @@ class TimerEngine: ObservableObject {
private func updateConfigurations() { private func updateConfigurations() {
logDebug("Updating timer configurations") logDebug("Updating timer configurations")
stateManager.updateConfigurations( stateManager.updateConfigurations(
using: timerConfigurations(), using: configurationHelper.configurations(),
userTimers: settingsProvider.settings.userTimers userTimers: settingsProvider.settings.userTimers
) )
} }
@@ -141,17 +143,7 @@ class TimerEngine: ObservableObject {
/// Unified way to get interval for any timer type /// Unified way to get interval for any timer type
private func getTimerInterval(for identifier: TimerIdentifier) -> Int { private func getTimerInterval(for identifier: TimerIdentifier) -> Int {
switch identifier { configurationHelper.intervalSeconds(for: 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
}
} }
func dismissReminder() { func dismissReminder() {
@@ -170,7 +162,7 @@ class TimerEngine: ObservableObject {
guard !state.isPaused else { continue } guard !state.isPaused else { continue }
guard state.isActive 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) skipNext(identifier: identifier)
continue continue
} }
@@ -218,13 +210,9 @@ class TimerEngine: ObservableObject {
} }
private func timerConfigurations() -> [TimerIdentifier: TimerConfiguration] { private func timerConfigurations() -> [TimerIdentifier: TimerConfiguration] {
var configurations: [TimerIdentifier: TimerConfiguration] = [:] configurationHelper.configurations()
for timerType in TimerType.allCases {
let config = settingsProvider.timerConfiguration(for: timerType)
configurations[.builtIn(timerType)] = config
}
return configurations
} }
} }
extension TimerEngine: TimerSchedulerDelegate { extension TimerEngine: TimerSchedulerDelegate {

View File

@@ -38,14 +38,12 @@ class TimerManager: ObservableObject {
// Add built-in timers (using unified approach) // Add built-in timers (using unified approach)
for timerType in TimerType.allCases { for timerType in TimerType.allCases {
let config = settingsProvider.timerConfiguration(for: timerType) let intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60
if config.enabled { if settingsProvider.isTimerEnabled(for: timerType) {
let identifier = TimerIdentifier.builtIn(timerType) let identifier = TimerIdentifier.builtIn(timerType)
newStates[identifier] = TimerState( newStates[identifier] = TimerStateBuilder.make(
identifier: identifier, identifier: identifier,
intervalSeconds: config.intervalSeconds, intervalSeconds: intervalSeconds
isPaused: false,
isActive: true
) )
} }
} }
@@ -53,11 +51,9 @@ class TimerManager: ObservableObject {
// Add user timers (using unified approach) // Add user timers (using unified approach)
for userTimer in settingsProvider.settings.userTimers where userTimer.enabled { for userTimer in settingsProvider.settings.userTimers where userTimer.enabled {
let identifier = TimerIdentifier.user(id: userTimer.id) let identifier = TimerIdentifier.user(id: userTimer.id)
newStates[identifier] = TimerState( newStates[identifier] = TimerStateBuilder.make(
identifier: identifier, identifier: identifier,
intervalSeconds: userTimer.intervalMinutes * 60, intervalSeconds: userTimer.intervalMinutes * 60
isPaused: false,
isActive: true
) )
} }
@@ -85,35 +81,30 @@ class TimerManager: ObservableObject {
// Update built-in timers (using unified approach) // Update built-in timers (using unified approach)
for timerType in TimerType.allCases { for timerType in TimerType.allCases {
let config = settingsProvider.timerConfiguration(for: timerType) let intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60
let identifier = TimerIdentifier.builtIn(timerType) let identifier = TimerIdentifier.builtIn(timerType)
if config.enabled { if settingsProvider.isTimerEnabled(for: timerType) {
if let existingState = timerStates[identifier] { if let existingState = timerStates[identifier] {
// Timer exists - check if interval changed // Timer exists - check if interval changed
if existingState.originalIntervalSeconds != config.intervalSeconds { if existingState.originalIntervalSeconds != intervalSeconds {
// Interval changed - reset with new interval // Interval changed - reset with new interval
newStates[identifier] = TimerState( var updatedState = existingState
identifier: identifier, updatedState.reset(intervalSeconds: intervalSeconds, keepPaused: true)
intervalSeconds: config.intervalSeconds, newStates[identifier] = updatedState
isPaused: existingState.isPaused,
isActive: true
)
} else { } else {
// Interval unchanged - keep existing state // Interval unchanged - keep existing state
newStates[identifier] = existingState newStates[identifier] = existingState
} }
} else { } else {
// Timer was just enabled - create new state // Timer was just enabled - create new state
newStates[identifier] = TimerState( newStates[identifier] = TimerStateBuilder.make(
identifier: identifier, identifier: identifier,
intervalSeconds: config.intervalSeconds, intervalSeconds: intervalSeconds
isPaused: false,
isActive: true
) )
} }
} }
// 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) // Update user timers (using unified approach)
@@ -126,23 +117,18 @@ class TimerManager: ObservableObject {
// Check if interval changed // Check if interval changed
if existingState.originalIntervalSeconds != newIntervalSeconds { if existingState.originalIntervalSeconds != newIntervalSeconds {
// Interval changed - reset with new interval // Interval changed - reset with new interval
newStates[identifier] = TimerState( var updatedState = existingState
identifier: identifier, updatedState.reset(intervalSeconds: newIntervalSeconds, keepPaused: true)
intervalSeconds: newIntervalSeconds, newStates[identifier] = updatedState
isPaused: existingState.isPaused,
isActive: true
)
} else { } else {
// Interval unchanged - keep existing state // Interval unchanged - keep existing state
newStates[identifier] = existingState newStates[identifier] = existingState
} }
} else { } else {
// New timer - create state // New timer - create state
newStates[identifier] = TimerState( newStates[identifier] = TimerStateBuilder.make(
identifier: identifier, identifier: identifier,
intervalSeconds: newIntervalSeconds, intervalSeconds: newIntervalSeconds
isPaused: false,
isActive: true
) )
} }
} }
@@ -158,7 +144,7 @@ class TimerManager: ObservableObject {
guard !state.isPaused else { continue } guard !state.isPaused else { continue }
guard state.isActive 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 // Timer has expired but with some grace period
continue continue
} }
@@ -211,20 +197,16 @@ class TimerManager: ObservableObject {
// Unified approach to get interval - no more separate handling for user timers // Unified approach to get interval - no more separate handling for user timers
let intervalSeconds = getTimerInterval(for: identifier) let intervalSeconds = getTimerInterval(for: identifier)
timerStates[identifier] = TimerState( var updatedState = state
identifier: identifier, updatedState.reset(intervalSeconds: intervalSeconds, keepPaused: true)
intervalSeconds: intervalSeconds, timerStates[identifier] = updatedState
isPaused: state.isPaused,
isActive: state.isActive
)
} }
/// Unified way to get interval for any timer type /// Unified way to get interval for any timer type
private func getTimerInterval(for identifier: TimerIdentifier) -> Int { private func getTimerInterval(for identifier: TimerIdentifier) -> Int {
switch identifier { switch identifier {
case .builtIn(let type): case .builtIn(let type):
let config = settingsProvider.timerConfiguration(for: type) return settingsProvider.timerIntervalMinutes(for: type) * 60
return config.intervalSeconds
case .user(let id): case .user(let id):
guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else { guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else {
return 0 return 0
@@ -234,8 +216,7 @@ class TimerManager: ObservableObject {
} }
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval { func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
guard let state = timerStates[identifier] else { return 0 } timerStates[identifier]?.remainingDuration ?? 0
return TimeInterval(state.remainingSeconds)
} }
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String { func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {

View File

@@ -16,7 +16,7 @@ struct SliderSection: View {
previewFunc: @escaping () -> Void previewFunc: @escaping () -> Void
) { ) {
self._intervalSettings = intervalSettings 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._enabled = enabled
self.type = type self.type = type
self.previewFunc = previewFunc self.previewFunc = previewFunc
@@ -27,10 +27,10 @@ struct SliderSection: View {
return "\(type) reminders are currently disabled." return "\(type) reminders are currently disabled."
} }
if countdownSettings.isNil && !intervalSettings.isNil { 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 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 { var body: some View {
@@ -46,15 +46,15 @@ struct SliderSection: View {
HStack { HStack {
Slider( Slider(
value: Binding( value: Binding(
get: { Double(intervalSettings.val ?? 0) }, get: { Double(intervalSettings.value ?? 0) },
set: { intervalSettings.val = Int($0) } set: { intervalSettings.value = Int($0) }
), ),
in: in:
Double( Double(
intervalSettings.range?.bounds.lowerBound ?? 0)...Double( intervalSettings.range?.bounds.lowerBound ?? 0)...Double(
intervalSettings.range?.bounds.upperBound ?? 100), intervalSettings.range?.bounds.upperBound ?? 100),
step: 5.0) step: 5.0)
Text("\(intervalSettings.val ?? 0) min") Text("\(intervalSettings.value ?? 0) min")
.frame(width: 60, alignment: .trailing) .frame(width: 60, alignment: .trailing)
.monospacedDigit() .monospacedDigit()
} }
@@ -66,14 +66,14 @@ struct SliderSection: View {
HStack { HStack {
Slider( Slider(
value: Binding( value: Binding(
get: { Double(countdownSettings.val ?? 0) }, get: { Double(countdownSettings.value ?? 0) },
set: { countdownSettings.val = Int($0) } set: { countdownSettings.value = Int($0) }
), ),
in: in:
Double( Double(
range.bounds.lowerBound)...Double(range.bounds.upperBound), range.bounds.lowerBound)...Double(range.bounds.upperBound),
step: 5.0) step: 5.0)
Text("\(countdownSettings.val ?? 0) sec") Text("\(countdownSettings.value ?? 0) sec")
.frame(width: 60, alignment: .trailing) .frame(width: 60, alignment: .trailing)
.monospacedDigit() .monospacedDigit()
} }

View File

@@ -254,7 +254,20 @@ struct AdditionalModifiersView: View {
} }
} }
Spacer() 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() .labelsHidden()
.disabled(!cameraService.hasCameraHardware) .disabled(!cameraService.hasCameraHardware)
.controlSize(isCompact ? .small : .regular) .controlSize(isCompact ? .small : .regular)

View File

@@ -44,11 +44,11 @@ struct BlinkSetupView: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
Toggle( Toggle(
"Enable Blink Reminders", isOn: $settingsManager.settings.blinkTimer.enabled "Enable Blink Reminders", isOn: $settingsManager.settings.blinkEnabled
) )
.font(.headline) .font(.headline)
if settingsManager.settings.blinkTimer.enabled { if settingsManager.settings.blinkEnabled {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Remind me every:") Text("Remind me every:")
.font(.subheadline) .font(.subheadline)
@@ -58,13 +58,10 @@ struct BlinkSetupView: View {
Slider( Slider(
value: Binding( value: Binding(
get: { get: {
Double( Double(settingsManager.settings.blinkIntervalMinutes)
settingsManager.settings.blinkTimer.intervalSeconds
/ 60)
}, },
set: { set: {
settingsManager.settings.blinkTimer.intervalSeconds = settingsManager.settings.blinkIntervalMinutes = Int($0)
Int($0) * 60
} }
), ),
in: 1...20, in: 1...20,
@@ -72,7 +69,7 @@ struct BlinkSetupView: View {
) )
Text( Text(
"\(settingsManager.settings.blinkTimer.intervalSeconds / 60) min" "\(settingsManager.settings.blinkIntervalMinutes) min"
) )
.frame(width: 60, alignment: .trailing) .frame(width: 60, alignment: .trailing)
.monospacedDigit() .monospacedDigit()
@@ -83,9 +80,9 @@ struct BlinkSetupView: View {
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
if settingsManager.settings.blinkTimer.enabled { if settingsManager.settings.blinkEnabled {
Text( 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) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -109,8 +106,7 @@ struct BlinkSetupView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffectIfAvailable( .glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10) GlassStyle.regular.tint(.green).interactive(), in: .rect(cornerRadius: 10))
)
} }
Spacer() Spacer()
@@ -129,6 +125,6 @@ struct BlinkSetupView: View {
} }
} }
#Preview("Blink Setup") { #Preview("Blink Setup View") {
BlinkSetupView(settingsManager: SettingsManager.shared) BlinkSetupView(settingsManager: SettingsManager.shared)
} }

View File

@@ -61,7 +61,9 @@ struct EnforceModeSetupView: View {
"", "",
isOn: Binding( isOn: Binding(
get: { get: {
settingsManager.settings.enforcementMode settingsManager.isTimerEnabled(for: .lookAway) ||
settingsManager.isTimerEnabled(for: .blink) ||
settingsManager.isTimerEnabled(for: .posture)
}, },
set: { newValue in set: { newValue in
print("🎛️ Toggle changed to: \(newValue)") print("🎛️ Toggle changed to: \(newValue)")
@@ -69,7 +71,6 @@ struct EnforceModeSetupView: View {
print("⚠️ Already processing toggle") print("⚠️ Already processing toggle")
return return
} }
settingsManager.settings.enforcementMode = newValue
handleEnforceModeToggle(enabled: newValue) handleEnforceModeToggle(enabled: newValue)
} }
) )
@@ -390,7 +391,6 @@ struct EnforceModeSetupView: View {
if enabled { if enabled {
guard cameraHardwareAvailable else { guard cameraHardwareAvailable else {
print("⚠️ Cannot enable enforce mode - no camera hardware") print("⚠️ Cannot enable enforce mode - no camera hardware")
settingsManager.settings.enforcementMode = false
return return
} }
print("🎛️ Enabling enforce mode...") print("🎛️ Enabling enforce mode...")
@@ -399,7 +399,6 @@ struct EnforceModeSetupView: View {
if !enforceModeService.isEnforceModeEnabled { if !enforceModeService.isEnforceModeEnabled {
print("⚠️ Failed to activate, reverting toggle") print("⚠️ Failed to activate, reverting toggle")
settingsManager.settings.enforcementMode = false
} }
} else { } else {
print("🎛️ Disabling enforce mode...") print("🎛️ Disabling enforce mode...")

View File

@@ -30,29 +30,18 @@ struct LookAwaySetupView: View {
intervalSettings: Binding( intervalSettings: Binding(
get: { get: {
RangeChoice( RangeChoice(
val: settingsManager.settings.lookAwayTimer.intervalSeconds / 60, value: settingsManager.settings.lookAwayIntervalMinutes,
range: Range(bounds: 5...60, step: 5) range: Range(bounds: 5...60, step: 5)
) )
}, },
set: { newValue in set: { newValue in
settingsManager.settings.lookAwayTimer.intervalSeconds = settingsManager.settings.lookAwayIntervalMinutes = newValue.value ?? 30
(newValue.val ?? 20) * 60
} }
), ),
countdownSettings: Binding( countdownSettings: nil,
get: { enabled: $settingsManager.settings.lookAwayEnabled,
RangeChoice( type: "Look Away",
val: settingsManager.settings.lookAwayCountdownSeconds, previewFunc: previewLookAway
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
) )
} }
@@ -63,11 +52,12 @@ struct LookAwaySetupView: View {
.background(.clear) .background(.clear)
} }
private func showPreviewWindow() { private func previewLookAway() {
guard let screen = NSScreen.main else { return } 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 PreviewWindowHelper.showPreview(on: screen) { dismiss in
LookAwayReminderView(countdownSeconds: countdownSeconds, onDismiss: dismiss) LookAwayReminderView(countdownSeconds: lookAwayIntervalMinutes * 60, onDismiss: dismiss)
} }
} }
} }

View File

@@ -46,17 +46,16 @@ struct PostureSetupView: View {
intervalSettings: Binding( intervalSettings: Binding(
get: { get: {
RangeChoice( RangeChoice(
val: settingsManager.settings.postureTimer.intervalSeconds / 60, value: settingsManager.settings.postureIntervalMinutes,
range: Range(bounds: 5...60, step: 5) range: Range(bounds: 5...60, step: 5)
) )
}, },
set: { newValue in set: { newValue in
settingsManager.settings.postureTimer.intervalSeconds = settingsManager.settings.postureIntervalMinutes = newValue.value ?? 30
(newValue.val ?? 30) * 60
} }
), ),
countdownSettings: nil, countdownSettings: nil,
enabled: $settingsManager.settings.postureTimer.enabled, enabled: $settingsManager.settings.postureEnabled,
type: "Posture", type: "Posture",
previewFunc: showPreviewWindow previewFunc: showPreviewWindow
) )

View File

@@ -53,12 +53,12 @@ final class AppDelegateTestabilityTests: XCTestCase {
let appDelegate = testEnv.createAppDelegate() let appDelegate = testEnv.createAppDelegate()
// Change a setting // Change a setting
testEnv.settingsManager.settings.lookAwayTimer.enabled = false testEnv.settingsManager.settings.lookAwayEnabled = false
try await Task.sleep(for: .milliseconds(50)) try await Task.sleep(for: .milliseconds(50))
// Verify the change propagated // Verify the change propagated
XCTAssertFalse(testEnv.settingsManager.settings.lookAwayTimer.enabled) XCTAssertFalse(testEnv.settingsManager.settings.lookAwayEnabled)
} }
func testOpenSettingsUsesWindowManager() { func testOpenSettingsUsesWindowManager() {

View File

@@ -79,30 +79,22 @@ final class OnboardingNavigationTests: XCTestCase {
func testSettingsPersistDuringNavigation() { func testSettingsPersistDuringNavigation() {
// Configure lookaway timer // Configure lookaway timer
var config = testEnv.settingsManager.settings.lookAwayTimer testEnv.settingsManager.settings.lookAwayEnabled = true
config.enabled = true testEnv.settingsManager.settings.lookAwayIntervalMinutes = 20
config.intervalSeconds = 1200
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config)
// Verify settings persisted // Verify settings persisted
let retrieved = testEnv.settingsManager.timerConfiguration(for: .lookAway) XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled)
XCTAssertTrue(retrieved.enabled) XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 20)
XCTAssertEqual(retrieved.intervalSeconds, 1200)
// Configure blink timer // Configure blink timer
var blinkConfig = testEnv.settingsManager.settings.blinkTimer testEnv.settingsManager.settings.blinkEnabled = false
blinkConfig.enabled = false testEnv.settingsManager.settings.blinkIntervalMinutes = 5
blinkConfig.intervalSeconds = 300
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
// Verify both settings persist // Verify both settings persist
let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway) XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled)
let blink = testEnv.settingsManager.timerConfiguration(for: .blink) XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 20)
XCTAssertFalse(testEnv.settingsManager.settings.blinkEnabled)
XCTAssertTrue(lookAway.enabled) XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 5)
XCTAssertEqual(lookAway.intervalSeconds, 1200)
XCTAssertFalse(blink.enabled)
XCTAssertEqual(blink.intervalSeconds, 300)
} }
func testOnboardingCompletion() { func testOnboardingCompletion() {
@@ -118,45 +110,26 @@ final class OnboardingNavigationTests: XCTestCase {
func testAllTimersConfiguredDuringOnboarding() { func testAllTimersConfiguredDuringOnboarding() {
// Configure all three built-in timers // Configure all three built-in timers
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer testEnv.settingsManager.settings.lookAwayEnabled = true
lookAwayConfig.enabled = true testEnv.settingsManager.settings.lookAwayIntervalMinutes = 20
lookAwayConfig.intervalSeconds = 1200 testEnv.settingsManager.settings.blinkEnabled = true
testEnv.settingsManager.updateTimerConfiguration( testEnv.settingsManager.settings.blinkIntervalMinutes = 5
for: .lookAway, configuration: lookAwayConfig) testEnv.settingsManager.settings.postureEnabled = true
testEnv.settingsManager.settings.postureIntervalMinutes = 30
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)
// Verify all configurations // Verify all configurations
let allConfigs = testEnv.settingsManager.allTimerConfigurations() XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled)
XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 20)
XCTAssertEqual(allConfigs[.lookAway]?.intervalSeconds, 1200) XCTAssertTrue(testEnv.settingsManager.settings.blinkEnabled)
XCTAssertEqual(allConfigs[.blink]?.intervalSeconds, 300) XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 5)
XCTAssertEqual(allConfigs[.posture]?.intervalSeconds, 1800) XCTAssertTrue(testEnv.settingsManager.settings.postureEnabled)
XCTAssertEqual(testEnv.settingsManager.settings.postureIntervalMinutes, 30)
XCTAssertTrue(allConfigs[.lookAway]?.enabled ?? false)
XCTAssertTrue(allConfigs[.blink]?.enabled ?? false)
XCTAssertTrue(allConfigs[.posture]?.enabled ?? false)
} }
func testNavigationWithPartialConfiguration() { func testNavigationWithPartialConfiguration() {
// Configure only some timers // Configure only some timers
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer testEnv.settingsManager.settings.lookAwayEnabled = true
lookAwayConfig.enabled = true testEnv.settingsManager.settings.blinkEnabled = false
testEnv.settingsManager.updateTimerConfiguration(
for: .lookAway, configuration: lookAwayConfig)
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
blinkConfig.enabled = false
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
// Should still be able to complete onboarding // Should still be able to complete onboarding
testEnv.settingsManager.settings.hasCompletedOnboarding = true testEnv.settingsManager.settings.hasCompletedOnboarding = true
@@ -181,23 +154,15 @@ final class OnboardingNavigationTests: XCTestCase {
// Page 1: MenuBar Welcome - no configuration needed // Page 1: MenuBar Welcome - no configuration needed
// Page 2: LookAway Setup // Page 2: LookAway Setup
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer testEnv.settingsManager.settings.lookAwayEnabled = true
lookAwayConfig.enabled = true testEnv.settingsManager.settings.lookAwayIntervalMinutes = 20
lookAwayConfig.intervalSeconds = 1200
testEnv.settingsManager.updateTimerConfiguration(
for: .lookAway, configuration: lookAwayConfig)
// Page 2: Blink Setup // Page 2: Blink Setup
var blinkConfig = testEnv.settingsManager.settings.blinkTimer testEnv.settingsManager.settings.blinkEnabled = true
blinkConfig.enabled = true testEnv.settingsManager.settings.blinkIntervalMinutes = 5
blinkConfig.intervalSeconds = 300
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
// Page 3: Posture Setup // Page 3: Posture Setup
var postureConfig = testEnv.settingsManager.settings.postureTimer testEnv.settingsManager.settings.postureEnabled = false // User chooses to disable this one
postureConfig.enabled = false // User chooses to disable this one
testEnv.settingsManager.updateTimerConfiguration(
for: .posture, configuration: postureConfig)
// Page 4: General Settings // Page 4: General Settings
testEnv.settingsManager.settings.playSounds = true testEnv.settingsManager.settings.playSounds = true
@@ -209,10 +174,9 @@ final class OnboardingNavigationTests: XCTestCase {
// Verify final state // Verify final state
XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding) XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding)
let finalConfigs = testEnv.settingsManager.allTimerConfigurations() XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled)
XCTAssertTrue(finalConfigs[.lookAway]?.enabled ?? false) XCTAssertTrue(testEnv.settingsManager.settings.blinkEnabled)
XCTAssertTrue(finalConfigs[.blink]?.enabled ?? false) XCTAssertFalse(testEnv.settingsManager.settings.postureEnabled)
XCTAssertFalse(finalConfigs[.posture]?.enabled ?? true)
XCTAssertTrue(testEnv.settingsManager.settings.playSounds) XCTAssertTrue(testEnv.settingsManager.settings.playSounds)
XCTAssertFalse(testEnv.settingsManager.settings.launchAtLogin) XCTAssertFalse(testEnv.settingsManager.settings.launchAtLogin)
@@ -220,24 +184,17 @@ final class OnboardingNavigationTests: XCTestCase {
func testNavigatingBackPreservesSettings() { func testNavigatingBackPreservesSettings() {
// Configure on page 1 // Configure on page 1
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer testEnv.settingsManager.settings.lookAwayIntervalMinutes = 25
lookAwayConfig.intervalSeconds = 1500
testEnv.settingsManager.updateTimerConfiguration(
for: .lookAway, configuration: lookAwayConfig)
// Move forward to page 2 // Move forward to page 2
var blinkConfig = testEnv.settingsManager.settings.blinkTimer testEnv.settingsManager.settings.blinkIntervalMinutes = 4
blinkConfig.intervalSeconds = 250
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
// Navigate back to page 1 // Navigate back to page 1
// Verify lookaway settings still exist // Verify lookaway settings still exist
let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway) XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 25)
XCTAssertEqual(lookAway.intervalSeconds, 1500)
// Navigate forward again to page 2 // Navigate forward again to page 2
// Verify blink settings still exist // Verify blink settings still exist
let blink = testEnv.settingsManager.timerConfiguration(for: .blink) XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 4)
XCTAssertEqual(blink.intervalSeconds, 250)
} }
} }

View File

@@ -22,8 +22,8 @@ final class ServiceContainerTests: XCTestCase {
let settings = AppSettings.onlyLookAwayEnabled let settings = AppSettings.onlyLookAwayEnabled
let container = TestServiceContainer(settings: settings) let container = TestServiceContainer(settings: settings)
XCTAssertEqual(container.settingsManager.settings.lookAwayTimer.enabled, true) XCTAssertEqual(container.settingsManager.settings.lookAwayEnabled, true)
XCTAssertEqual(container.settingsManager.settings.blinkTimer.enabled, false) XCTAssertEqual(container.settingsManager.settings.blinkEnabled, false)
} }
func testTimerEngineCreation() { func testTimerEngineCreation() {

View File

@@ -38,52 +38,42 @@ final class SettingsManagerTests: XCTestCase {
func testDefaultSettingsValues() { func testDefaultSettingsValues() {
let defaults = AppSettings.defaults let defaults = AppSettings.defaults
XCTAssertTrue(defaults.lookAwayTimer.enabled) XCTAssertTrue(defaults.lookAwayEnabled)
XCTAssertFalse(defaults.blinkTimer.enabled) // Blink timer is disabled by default XCTAssertFalse(defaults.blinkEnabled) // Blink timer is disabled by default
XCTAssertTrue(defaults.postureTimer.enabled) XCTAssertTrue(defaults.postureEnabled)
XCTAssertFalse(defaults.hasCompletedOnboarding) XCTAssertFalse(defaults.hasCompletedOnboarding)
} }
// MARK: - Timer Configuration Tests // MARK: - Timer Configuration Tests
func testGetTimerConfiguration() { func testGetTimerConfiguration() {
let lookAwayConfig = settingsManager.timerConfiguration(for: .lookAway) XCTAssertTrue(settingsManager.settings.lookAwayEnabled)
XCTAssertNotNil(lookAwayConfig)
XCTAssertTrue(lookAwayConfig.enabled)
} }
func testUpdateTimerConfiguration() { func testUpdateTimerConfiguration() {
var config = settingsManager.timerConfiguration(for: .lookAway) settingsManager.settings.lookAwayEnabled = false
config.intervalSeconds = 1500 settingsManager.settings.lookAwayIntervalMinutes = 25
config.enabled = false
settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) XCTAssertFalse(settingsManager.settings.lookAwayEnabled)
XCTAssertEqual(settingsManager.settings.lookAwayIntervalMinutes, 25)
let updated = settingsManager.timerConfiguration(for: .lookAway)
XCTAssertEqual(updated.intervalSeconds, 1500)
XCTAssertFalse(updated.enabled)
} }
func testAllTimerConfigurations() { func testAllTimerConfigurations() {
let allConfigs = settingsManager.allTimerConfigurations() XCTAssertEqual(settingsManager.settings.lookAwayEnabled, true)
XCTAssertEqual(settingsManager.settings.blinkEnabled, false)
XCTAssertEqual(allConfigs.count, 3) XCTAssertEqual(settingsManager.settings.postureEnabled, true)
XCTAssertNotNil(allConfigs[.lookAway])
XCTAssertNotNil(allConfigs[.blink])
XCTAssertNotNil(allConfigs[.posture])
} }
func testUpdateMultipleTimerConfigurations() { func testUpdateMultipleTimerConfigurations() {
var lookAway = settingsManager.timerConfiguration(for: .lookAway) settingsManager.settings.lookAwayEnabled = true
lookAway.intervalSeconds = 1000 settingsManager.settings.lookAwayIntervalMinutes = 16
settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAway) settingsManager.settings.blinkEnabled = true
settingsManager.settings.blinkIntervalMinutes = 4
var blink = settingsManager.timerConfiguration(for: .blink) XCTAssertTrue(settingsManager.settings.lookAwayEnabled)
blink.intervalSeconds = 250 XCTAssertEqual(settingsManager.settings.lookAwayIntervalMinutes, 16)
settingsManager.updateTimerConfiguration(for: .blink, configuration: blink) XCTAssertTrue(settingsManager.settings.blinkEnabled)
XCTAssertEqual(settingsManager.settings.blinkIntervalMinutes, 4)
XCTAssertEqual(settingsManager.timerConfiguration(for: .lookAway).intervalSeconds, 1000)
XCTAssertEqual(settingsManager.timerConfiguration(for: .blink).intervalSeconds, 250)
} }
// MARK: - Settings Publisher Tests // MARK: - Settings Publisher Tests
@@ -138,9 +128,8 @@ final class SettingsManagerTests: XCTestCase {
// Modify settings // Modify settings
settingsManager.settings.playSounds = false settingsManager.settings.playSounds = false
settingsManager.settings.launchAtLogin = true settingsManager.settings.launchAtLogin = true
var config = settingsManager.timerConfiguration(for: .lookAway) settingsManager.settings.lookAwayEnabled = false
config.intervalSeconds = 5000 settingsManager.settings.lookAwayIntervalMinutes = 10
settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config)
// Reset // Reset
settingsManager.resetToDefaults() settingsManager.resetToDefaults()
@@ -149,6 +138,8 @@ final class SettingsManagerTests: XCTestCase {
let defaults = AppSettings.defaults let defaults = AppSettings.defaults
XCTAssertEqual(settingsManager.settings.playSounds, defaults.playSounds) XCTAssertEqual(settingsManager.settings.playSounds, defaults.playSounds)
XCTAssertEqual(settingsManager.settings.launchAtLogin, defaults.launchAtLogin) XCTAssertEqual(settingsManager.settings.launchAtLogin, defaults.launchAtLogin)
XCTAssertEqual(settingsManager.settings.lookAwayEnabled, defaults.lookAwayEnabled)
XCTAssertEqual(settingsManager.settings.lookAwayIntervalMinutes, defaults.lookAwayIntervalMinutes)
} }
// MARK: - Onboarding Tests // MARK: - Onboarding Tests

View File

@@ -282,9 +282,9 @@ final class TimerEngineTests: XCTestCase {
func testDisabledTimersNotInitialized() { func testDisabledTimersNotInitialized() {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = false settings.lookAwayEnabled = false
settings.blinkTimer.enabled = false settings.blinkEnabled = false
settings.postureTimer.enabled = false settings.postureEnabled = false
let settingsManager = EnhancedMockSettingsManager(settings: settings) let settingsManager = EnhancedMockSettingsManager(settings: settings)
let engine = TimerEngine(settingsManager: settingsManager) let engine = TimerEngine(settingsManager: settingsManager)
@@ -296,9 +296,9 @@ final class TimerEngineTests: XCTestCase {
func testPartiallyEnabledTimers() { func testPartiallyEnabledTimers() {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = true settings.lookAwayEnabled = true
settings.blinkTimer.enabled = false settings.blinkEnabled = false
settings.postureTimer.enabled = false settings.postureEnabled = false
let settingsManager = EnhancedMockSettingsManager(settings: settings) let settingsManager = EnhancedMockSettingsManager(settings: settings)
let engine = TimerEngine(settingsManager: settingsManager) let engine = TimerEngine(settingsManager: settingsManager)

View File

@@ -28,11 +28,19 @@ final class EnhancedMockSettingsManager: SettingsProviding {
} }
@ObservationIgnored @ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = private let intervalKeyPaths: [TimerType: WritableKeyPath<AppSettings, Int>] =
[ [
.lookAway: \.lookAwayTimer, .lookAway: \.lookAwayIntervalMinutes,
.blink: \.blinkTimer, .blink: \.blinkIntervalMinutes,
.posture: \.postureTimer, .posture: \.postureIntervalMinutes,
]
@ObservationIgnored
private let enabledKeyPaths: [TimerType: WritableKeyPath<AppSettings, Bool>] =
[
.lookAway: \.lookAwayEnabled,
.blink: \.blinkEnabled,
.posture: \.postureEnabled,
] ]
// Track method calls for verification // Track method calls for verification
@@ -50,27 +58,42 @@ final class EnhancedMockSettingsManager: SettingsProviding {
self._settingsSubject = CurrentValueSubject(settings) self._settingsSubject = CurrentValueSubject(settings)
} }
func timerConfiguration(for type: TimerType) -> TimerConfiguration { func timerIntervalMinutes(for type: TimerType) -> Int {
guard let keyPath = timerConfigKeyPaths[type] else { guard let keyPath = intervalKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)") preconditionFailure("Unknown timer type: \(type)")
} }
return settings[keyPath: keyPath] return settings[keyPath: keyPath]
} }
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { func isTimerEnabled(for type: TimerType) -> Bool {
guard let keyPath = timerConfigKeyPaths[type] else { guard let keyPath = enabledKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)") 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) _settingsSubject.send(settings)
} }
func allTimerConfigurations() -> [TimerType: TimerConfiguration] { func updateTimerEnabled(for type: TimerType, enabled: Bool) {
var configs: [TimerType: TimerConfiguration] = [:] guard let keyPath = enabledKeyPaths[type] else {
for (type, keyPath) in timerConfigKeyPaths { preconditionFailure("Unknown timer type: \(type)")
configs[type] = settings[keyPath: keyPath]
} }
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() { func save() {
@@ -155,27 +178,27 @@ extension AppSettings {
/// Settings with all timers disabled /// Settings with all timers disabled
static var allTimersDisabled: AppSettings { static var allTimersDisabled: AppSettings {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = false settings.lookAwayEnabled = false
settings.blinkTimer.enabled = false settings.blinkEnabled = false
settings.postureTimer.enabled = false settings.postureEnabled = false
return settings return settings
} }
/// Settings with only lookAway timer enabled /// Settings with only lookAway timer enabled
static var onlyLookAwayEnabled: AppSettings { static var onlyLookAwayEnabled: AppSettings {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = true settings.lookAwayEnabled = true
settings.blinkTimer.enabled = false settings.blinkEnabled = false
settings.postureTimer.enabled = false settings.postureEnabled = false
return settings return settings
} }
/// Settings with short intervals for testing /// Settings with short intervals for testing
static var shortIntervals: AppSettings { static var shortIntervals: AppSettings {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.intervalSeconds = 5 settings.lookAwayIntervalMinutes = 5
settings.blinkTimer.intervalSeconds = 3 settings.blinkIntervalMinutes = 3
settings.postureTimer.intervalSeconds = 7 settings.postureIntervalMinutes = 7
return settings return settings
} }

View File

@@ -40,9 +40,9 @@ final class TimerEngineTestabilityTests: XCTestCase {
func testTimerEngineUsesInjectedSettings() { func testTimerEngineUsesInjectedSettings() {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = true settings.lookAwayEnabled = true
settings.blinkTimer.enabled = false settings.blinkEnabled = false
settings.postureTimer.enabled = false settings.postureEnabled = false
testEnv.settingsManager.settings = settings testEnv.settingsManager.settings = settings
let timerEngine = testEnv.container.timerEngine let timerEngine = testEnv.container.timerEngine

View File

@@ -29,40 +29,41 @@ final class BlinkSetupViewTests: XCTestCase {
} }
func testBlinkTimerConfigurationChanges() { func testBlinkTimerConfigurationChanges() {
let initial = testEnv.settingsManager.timerConfiguration(for: .blink) XCTAssertFalse(testEnv.settingsManager.settings.blinkEnabled)
XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 7)
var modified = initial testEnv.settingsManager.settings.blinkEnabled = true
modified.enabled = true testEnv.settingsManager.settings.blinkIntervalMinutes = 5
modified.intervalSeconds = 300
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: modified)
let updated = testEnv.settingsManager.timerConfiguration(for: .blink) XCTAssertTrue(testEnv.settingsManager.settings.blinkEnabled)
XCTAssertTrue(updated.enabled) XCTAssertEqual(testEnv.settingsManager.settings.blinkIntervalMinutes, 5)
XCTAssertEqual(updated.intervalSeconds, 300)
} }
func testBlinkTimerEnableDisable() { func testBlinkTimerEnableDisable() {
var config = testEnv.settingsManager.timerConfiguration(for: .blink) var config = testEnv.settingsManager.settings
config.enabled = true config.blinkEnabled = true
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) config.blinkIntervalMinutes = 4
XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .blink).enabled) testEnv.settingsManager.settings = config
XCTAssertTrue(testEnv.settingsManager.settings.blinkEnabled)
config.enabled = false config.blinkEnabled = false
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) config.blinkIntervalMinutes = 3
XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .blink).enabled) testEnv.settingsManager.settings = config
XCTAssertFalse(testEnv.settingsManager.settings.blinkEnabled)
} }
func testBlinkIntervalValidation() { func testBlinkIntervalValidation() {
var config = testEnv.settingsManager.timerConfiguration(for: .blink) var config = testEnv.settingsManager.settings
let intervals = [180, 240, 300, 360, 600] let intervals = [3, 4, 5, 6, 10]
for interval in intervals { for minutes in intervals {
config.intervalSeconds = interval config.blinkEnabled = true
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) config.blinkIntervalMinutes = minutes
testEnv.settingsManager.settings = config
let retrieved = testEnv.settingsManager.timerConfiguration(for: .blink) let retrieved = testEnv.settingsManager.settings
XCTAssertEqual(retrieved.intervalSeconds, interval) XCTAssertEqual(retrieved.blinkIntervalMinutes, minutes)
} }
} }

View File

@@ -30,45 +30,46 @@ final class LookAwaySetupViewTests: XCTestCase {
func testLookAwayTimerConfigurationChanges() { func testLookAwayTimerConfigurationChanges() {
// Start with default // Start with default
let initial = testEnv.settingsManager.timerConfiguration(for: .lookAway) XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled)
XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 20)
// Modify configuration // Modify configuration
var modified = initial testEnv.settingsManager.settings.lookAwayEnabled = true
modified.enabled = true testEnv.settingsManager.settings.lookAwayIntervalMinutes = 25
modified.intervalSeconds = 1500
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: modified)
// Verify changes // Verify changes
let updated = testEnv.settingsManager.timerConfiguration(for: .lookAway) XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled)
XCTAssertTrue(updated.enabled) XCTAssertEqual(testEnv.settingsManager.settings.lookAwayIntervalMinutes, 25)
XCTAssertEqual(updated.intervalSeconds, 1500)
} }
func testLookAwayTimerEnableDisable() { func testLookAwayTimerEnableDisable() {
var config = testEnv.settingsManager.timerConfiguration(for: .lookAway) var config = testEnv.settingsManager.settings
// Enable // Enable
config.enabled = true config.lookAwayEnabled = true
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) config.lookAwayIntervalMinutes = 15
XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .lookAway).enabled) testEnv.settingsManager.settings = config
XCTAssertTrue(testEnv.settingsManager.settings.lookAwayEnabled)
// Disable // Disable
config.enabled = false config.lookAwayEnabled = false
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) config.lookAwayIntervalMinutes = 10
XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .lookAway).enabled) testEnv.settingsManager.settings = config
XCTAssertFalse(testEnv.settingsManager.settings.lookAwayEnabled)
} }
func testLookAwayIntervalValidation() { func testLookAwayIntervalValidation() {
var config = testEnv.settingsManager.timerConfiguration(for: .lookAway) var config = testEnv.settingsManager.settings
// Test various intervals // Test various intervals (in minutes)
let intervals = [300, 600, 1200, 1800, 3600] let intervals = [5, 10, 20, 30, 60]
for interval in intervals { for minutes in intervals {
config.intervalSeconds = interval config.lookAwayEnabled = true
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) config.lookAwayIntervalMinutes = minutes
testEnv.settingsManager.settings = config
let retrieved = testEnv.settingsManager.timerConfiguration(for: .lookAway) let retrieved = testEnv.settingsManager.settings
XCTAssertEqual(retrieved.intervalSeconds, interval) XCTAssertEqual(retrieved.lookAwayIntervalMinutes, minutes)
} }
} }

View File

@@ -29,40 +29,47 @@ final class PostureSetupViewTests: XCTestCase {
} }
func testPostureTimerConfigurationChanges() { 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 // Modify configuration
modified.enabled = true testEnv.settingsManager.settings.postureEnabled = true
modified.intervalSeconds = 1800 testEnv.settingsManager.settings.postureIntervalMinutes = 45
testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: modified)
let updated = testEnv.settingsManager.timerConfiguration(for: .posture) // Verify changes
XCTAssertTrue(updated.enabled) XCTAssertTrue(testEnv.settingsManager.settings.postureEnabled)
XCTAssertEqual(updated.intervalSeconds, 1800) XCTAssertEqual(testEnv.settingsManager.settings.postureIntervalMinutes, 45)
} }
func testPostureTimerEnableDisable() { func testPostureTimerEnableDisable() {
var config = testEnv.settingsManager.timerConfiguration(for: .posture) var config = testEnv.settingsManager.settings
config.enabled = true // Enable
testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) config.postureEnabled = true
XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .posture).enabled) config.postureIntervalMinutes = 25
testEnv.settingsManager.settings = config
XCTAssertTrue(testEnv.settingsManager.settings.postureEnabled)
config.enabled = false // Disable
testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) config.postureEnabled = false
XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .posture).enabled) config.postureIntervalMinutes = 20
testEnv.settingsManager.settings = config
XCTAssertFalse(testEnv.settingsManager.settings.postureEnabled)
} }
func testPostureIntervalValidation() { func testPostureIntervalValidation() {
var config = testEnv.settingsManager.timerConfiguration(for: .posture) var config = testEnv.settingsManager.settings
let intervals = [900, 1200, 1800, 2400, 3600] // Test various intervals (in minutes)
for interval in intervals { let intervals = [15, 20, 30, 45, 60]
config.intervalSeconds = interval for minutes in intervals {
testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) config.postureEnabled = true
config.postureIntervalMinutes = minutes
testEnv.settingsManager.settings = config
let retrieved = testEnv.settingsManager.timerConfiguration(for: .posture) let retrieved = testEnv.settingsManager.settings
XCTAssertEqual(retrieved.intervalSeconds, interval) XCTAssertEqual(retrieved.postureIntervalMinutes, minutes)
} }
} }