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

View File

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

View File

@@ -76,29 +76,41 @@ struct GazeSample: Codable {
let faceWidthRatio: Double? // For distance scaling (face width / image width)
let timestamp: Date
init(leftRatio: Double?, rightRatio: Double?, leftVerticalRatio: Double? = nil, rightVerticalRatio: Double? = nil, faceWidthRatio: Double? = nil) {
init(
leftRatio: Double?,
rightRatio: Double?,
leftVerticalRatio: Double? = nil,
rightVerticalRatio: Double? = nil,
faceWidthRatio: Double? = nil
) {
self.leftRatio = leftRatio
self.rightRatio = rightRatio
self.leftVerticalRatio = leftVerticalRatio
self.rightVerticalRatio = rightVerticalRatio
self.faceWidthRatio = faceWidthRatio
// Calculate average horizontal ratio
if let left = leftRatio, let right = rightRatio {
self.averageRatio = (left + right) / 2.0
} else {
self.averageRatio = leftRatio ?? rightRatio ?? 0.5
}
// Calculate average vertical ratio
if let left = leftVerticalRatio, let right = rightVerticalRatio {
self.averageVerticalRatio = (left + right) / 2.0
} else {
self.averageVerticalRatio = leftVerticalRatio ?? rightVerticalRatio ?? 0.5
}
self.averageRatio = GazeSample.average(left: leftRatio, right: rightRatio, fallback: 0.5)
self.averageVerticalRatio = GazeSample.average(
left: leftVerticalRatio,
right: rightVerticalRatio,
fallback: 0.5
)
self.timestamp = Date()
}
private static func average(left: Double?, right: Double?, fallback: Double) -> Double {
switch (left, right) {
case let (left?, right?):
return (left + right) / 2.0
case let (left?, nil):
return left
case let (nil, right?):
return right
default:
return fallback
}
}
}
struct GazeThresholds: Codable {
@@ -121,10 +133,14 @@ struct GazeThresholds: Codable {
let referenceFaceWidth: Double // Average face width during calibration
var isValid: Bool {
// Just check that we have reasonable values (not NaN or infinite)
let values = [minLeftRatio, maxRightRatio, minUpRatio, maxDownRatio,
screenLeftBound, screenRightBound, screenTopBound, screenBottomBound]
return values.allSatisfy { $0.isFinite }
isFiniteValues([
minLeftRatio, maxRightRatio, minUpRatio, maxDownRatio,
screenLeftBound, screenRightBound, screenTopBound, screenBottomBound,
])
}
private func isFiniteValues(_ values: [Double]) -> Bool {
values.allSatisfy { $0.isFinite }
}
/// Default thresholds based on video test data:
@@ -154,6 +170,14 @@ struct CalibrationData: Codable {
var computedThresholds: GazeThresholds?
var calibrationDate: Date
var isComplete: Bool
private let thresholdCalculator = CalibrationThresholdCalculator()
enum CodingKeys: String, CodingKey {
case samples
case computedThresholds
case calibrationDate
case isComplete
}
init() {
self.samples = [:]
@@ -193,152 +217,20 @@ struct CalibrationData: Codable {
}
mutating func calculateThresholds() {
// Calibration uses actual measured gaze ratios from the user looking at different
// screen positions. The face width during calibration serves as a reference for
// distance-based normalization during live tracking.
//
// Coordinate system (based on video testing):
// Horizontal: 0.0 = far right, 1.0 = far left
// Vertical: 0.0 = top, 1.0 = bottom
// Center (looking at screen) typically: H 0.29-0.35
// 1. Get center reference point
let centerH = averageRatio(for: .center)
let centerV = averageVerticalRatio(for: .center)
let centerFaceWidth = averageFaceWidth(for: .center)
guard let cH = centerH else {
print("⚠️ No center calibration data, using defaults")
self.computedThresholds = GazeThresholds.defaultThresholds
return
self.computedThresholds = thresholdCalculator.calculate(using: self)
logStepData()
}
let cV = centerV ?? 0.45 // Default vertical center
print("📊 Calibration data collected:")
print(" Center H: \(String(format: "%.3f", cH)), V: \(String(format: "%.3f", cV))")
// 2. Get horizontal screen bounds from left/right calibration points
// These represent where the user looked when targeting screen edges
// Use farLeft/farRight for "beyond screen" thresholds, left/right for screen bounds
// Screen bounds (where user looked at screen edges)
let screenLeftH = averageRatio(for: .left)
?? averageRatio(for: .topLeft)
?? averageRatio(for: .bottomLeft)
let screenRightH = averageRatio(for: .right)
?? averageRatio(for: .topRight)
?? averageRatio(for: .bottomRight)
// Far bounds (where user looked beyond screen - for "looking away" threshold)
let farLeftH = averageRatio(for: .farLeft)
let farRightH = averageRatio(for: .farRight)
// 3. Calculate horizontal thresholds
// If we have farLeft/farRight, use the midpoint between screen edge and far as threshold
// Otherwise, extend screen bounds by a margin
let leftBound: Double
let rightBound: Double
let lookLeftThreshold: Double
let lookRightThreshold: Double
if let sLeft = screenLeftH {
leftBound = sLeft
// If we have farLeft, threshold is midpoint; otherwise extend by margin
if let fLeft = farLeftH {
lookLeftThreshold = (sLeft + fLeft) / 2.0
} else {
// Extend beyond screen by ~50% of center-to-edge distance
let edgeDistance = sLeft - cH
lookLeftThreshold = sLeft + edgeDistance * 0.5
}
} else {
// No left calibration - estimate based on center
leftBound = cH + 0.15
lookLeftThreshold = cH + 0.20
}
if let sRight = screenRightH {
rightBound = sRight
if let fRight = farRightH {
lookRightThreshold = (sRight + fRight) / 2.0
} else {
let edgeDistance = cH - sRight
lookRightThreshold = sRight - edgeDistance * 0.5
}
} else {
rightBound = cH - 0.15
lookRightThreshold = cH - 0.20
}
// 4. Get vertical screen bounds
let screenTopV = averageVerticalRatio(for: .up)
?? averageVerticalRatio(for: .topLeft)
?? averageVerticalRatio(for: .topRight)
let screenBottomV = averageVerticalRatio(for: .down)
?? averageVerticalRatio(for: .bottomLeft)
?? averageVerticalRatio(for: .bottomRight)
let topBound: Double
let bottomBound: Double
let lookUpThreshold: Double
let lookDownThreshold: Double
if let sTop = screenTopV {
topBound = sTop
let edgeDistance = cV - sTop
lookUpThreshold = sTop - edgeDistance * 0.5
} else {
topBound = cV - 0.10
lookUpThreshold = cV - 0.15
}
if let sBottom = screenBottomV {
bottomBound = sBottom
let edgeDistance = sBottom - cV
lookDownThreshold = sBottom + edgeDistance * 0.5
} else {
bottomBound = cV + 0.10
lookDownThreshold = cV + 0.15
}
// 5. Reference face width for distance normalization
// Average face width from all calibration steps gives a good reference
let allFaceWidths = CalibrationStep.allCases.compactMap { averageFaceWidth(for: $0) }
let refFaceWidth = allFaceWidths.isEmpty ? 0.0 : allFaceWidths.reduce(0.0, +) / Double(allFaceWidths.count)
// 6. Create thresholds
let thresholds = GazeThresholds(
minLeftRatio: lookLeftThreshold,
maxRightRatio: lookRightThreshold,
minUpRatio: lookUpThreshold,
maxDownRatio: lookDownThreshold,
screenLeftBound: leftBound,
screenRightBound: rightBound,
screenTopBound: topBound,
screenBottomBound: bottomBound,
referenceFaceWidth: refFaceWidth
)
self.computedThresholds = thresholds
print("✓ Calibration thresholds calculated:")
print(" Center: H=\(String(format: "%.3f", cH)), V=\(String(format: "%.3f", cV))")
print(" Screen H-Range: \(String(format: "%.3f", rightBound)) to \(String(format: "%.3f", leftBound))")
print(" Screen V-Range: \(String(format: "%.3f", topBound)) to \(String(format: "%.3f", bottomBound))")
print(" Away Thresholds: L≥\(String(format: "%.3f", lookLeftThreshold)), R≤\(String(format: "%.3f", lookRightThreshold))")
print(" Away Thresholds: U≤\(String(format: "%.3f", lookUpThreshold)), D≥\(String(format: "%.3f", lookDownThreshold))")
print(" Ref Face Width: \(String(format: "%.3f", refFaceWidth))")
// Log per-step data for debugging
private func logStepData() {
print(" Per-step data:")
for step in CalibrationStep.allCases {
if let h = averageRatio(for: step) {
let v = averageVerticalRatio(for: step) ?? -1
let fw = averageFaceWidth(for: step) ?? -1
let count = getSamples(for: step).count
print(" \(step.rawValue): H=\(String(format: "%.3f", h)), V=\(String(format: "%.3f", v)), FW=\(String(format: "%.3f", fw)), samples=\(count)")
print(
" \(step.rawValue): H=\(String(format: "%.3f", h)), V=\(String(format: "%.3f", v)), FW=\(String(format: "%.3f", fw)), samples=\(count)"
)
}
}
}
@@ -361,4 +253,29 @@ class CalibrationState: @unchecked Sendable {
get { queue.sync { _isComplete } }
set { queue.async(flags: .barrier) { self._isComplete = newValue } }
}
func reset() {
setState(thresholds: nil, isComplete: false)
}
func setThresholds(_ thresholds: GazeThresholds?) {
setState(thresholds: thresholds, isComplete: nil)
}
func setComplete(_ isComplete: Bool) {
setState(thresholds: nil, isComplete: isComplete)
}
private func setState(thresholds: GazeThresholds?, isComplete: Bool?) {
queue.async(flags: .barrier) {
if let thresholds {
self._thresholds = thresholds
} else if isComplete == nil {
self._thresholds = nil
}
if let isComplete {
self._isComplete = isComplete
}
}
}
}

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 pauseReasons: Set<PauseReason>
var isActive: Bool
var targetDate: Date
let originalIntervalSeconds: Int
let lastResetDate: Date
init(identifier: TimerIdentifier, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) {
self.identifier = identifier
self.remainingSeconds = intervalSeconds
self.isPaused = isPaused
self.pauseReasons = []
self.isActive = isActive
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
self.originalIntervalSeconds = intervalSeconds
func targetDate(using timeProvider: TimeProviding) -> Date {
lastResetDate.addingTimeInterval(Double(originalIntervalSeconds))
}
var remainingDuration: TimeInterval {
TimeInterval(remainingSeconds)
}
func isExpired(using timeProvider: TimeProviding) -> Bool {
targetDate(using: timeProvider) <= timeProvider.now()
}
var formattedDuration: String {
remainingDuration.formatAsTimerDurationFull()
}
mutating func reset(intervalSeconds: Int? = nil, keepPaused: Bool = true) {
let newIntervalSeconds = intervalSeconds ?? originalIntervalSeconds
let newPauseReasons = keepPaused ? pauseReasons : []
self = TimerStateBuilder.make(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: keepPaused ? isPaused : false,
pauseReasons: newPauseReasons,
isActive: isActive
)
}
}
enum TimerStateBuilder {
static func make(
identifier: TimerIdentifier,
intervalSeconds: Int,
isPaused: Bool = false,
pauseReasons: Set<PauseReason> = [],
isActive: Bool = true,
lastResetDate: Date = Date()
) -> TimerState {
TimerState(
identifier: identifier,
remainingSeconds: intervalSeconds,
isPaused: isPaused,
pauseReasons: pauseReasons,
isActive: isActive,
originalIntervalSeconds: intervalSeconds,
lastResetDate: lastResetDate
)
}
}

View File

@@ -7,13 +7,17 @@ import Combine
import Foundation
@MainActor
protocol SettingsProviding: AnyObject, Observable {
protocol TimerSettingsProviding {
func allTimerSettings() -> [TimerType: (enabled: Bool, intervalMinutes: Int)]
func isTimerEnabled(for type: TimerType) -> Bool
func timerIntervalMinutes(for type: TimerType) -> Int
}
@MainActor
protocol SettingsProviding: AnyObject, Observable, TimerSettingsProviding {
var settings: AppSettings { get set }
var settingsPublisher: AnyPublisher<AppSettings, Never> { get }
func timerConfiguration(for type: TimerType) -> TimerConfiguration
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration)
func allTimerConfigurations() -> [TimerType: TimerConfiguration]
func save()
func saveImmediately()
func load()

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

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() {
stopFaceDetectionTimer()
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor [weak self] in
self?.checkFaceDetectionTimeout()
}
}
}
private func stopFaceDetectionTimer() {

View File

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

View File

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

View File

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

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 {
case .lookAway:
activeReminder = .lookAwayTriggered(
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds)
countdownSeconds: settingsProvider.timerIntervalMinutes(for: .lookAway) * 60)
case .blink:
activeReminder = .blinkTriggered
case .posture:

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -254,7 +254,20 @@ struct AdditionalModifiersView: View {
}
}
Spacer()
Toggle("", isOn: $settingsManager.settings.enforcementMode)
Toggle("", isOn: Binding(
get: {
settingsManager.isTimerEnabled(for: .lookAway) ||
settingsManager.isTimerEnabled(for: .blink) ||
settingsManager.isTimerEnabled(for: .posture)
},
set: { newValue in
if newValue {
Task { @MainActor in
try await cameraService.requestCameraAccess()
}
}
}
))
.labelsHidden()
.disabled(!cameraService.hasCameraHardware)
.controlSize(isCompact ? .small : .regular)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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