oof
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
Gaze/Models/CalibrationThresholdCalculator.swift
Normal file
158
Gaze/Models/CalibrationThresholdCalculator.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
//
|
||||||
|
// CalibrationThresholdCalculator.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/29/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CalibrationThresholdCalculator {
|
||||||
|
func calculate(using data: CalibrationData) -> GazeThresholds? {
|
||||||
|
let centerH = data.averageRatio(for: .center)
|
||||||
|
let centerV = data.averageVerticalRatio(for: .center)
|
||||||
|
|
||||||
|
guard let cH = centerH else {
|
||||||
|
print("⚠️ No center calibration data, using defaults")
|
||||||
|
return GazeThresholds.defaultThresholds
|
||||||
|
}
|
||||||
|
|
||||||
|
let cV = centerV ?? 0.45
|
||||||
|
|
||||||
|
print("📊 Calibration data collected:")
|
||||||
|
print(" Center H: \(String(format: "%.3f", cH)), V: \(String(format: "%.3f", cV))")
|
||||||
|
|
||||||
|
let screenLeftH = data.averageRatio(for: .left)
|
||||||
|
?? data.averageRatio(for: .topLeft)
|
||||||
|
?? data.averageRatio(for: .bottomLeft)
|
||||||
|
let screenRightH = data.averageRatio(for: .right)
|
||||||
|
?? data.averageRatio(for: .topRight)
|
||||||
|
?? data.averageRatio(for: .bottomRight)
|
||||||
|
|
||||||
|
let farLeftH = data.averageRatio(for: .farLeft)
|
||||||
|
let farRightH = data.averageRatio(for: .farRight)
|
||||||
|
|
||||||
|
let (leftBound, lookLeftThreshold) = horizontalBounds(
|
||||||
|
center: cH,
|
||||||
|
screenEdge: screenLeftH,
|
||||||
|
farEdge: farLeftH,
|
||||||
|
direction: .left
|
||||||
|
)
|
||||||
|
let (rightBound, lookRightThreshold) = horizontalBounds(
|
||||||
|
center: cH,
|
||||||
|
screenEdge: screenRightH,
|
||||||
|
farEdge: farRightH,
|
||||||
|
direction: .right
|
||||||
|
)
|
||||||
|
|
||||||
|
let screenTopV = data.averageVerticalRatio(for: .up)
|
||||||
|
?? data.averageVerticalRatio(for: .topLeft)
|
||||||
|
?? data.averageVerticalRatio(for: .topRight)
|
||||||
|
let screenBottomV = data.averageVerticalRatio(for: .down)
|
||||||
|
?? data.averageVerticalRatio(for: .bottomLeft)
|
||||||
|
?? data.averageVerticalRatio(for: .bottomRight)
|
||||||
|
|
||||||
|
let (topBound, lookUpThreshold) = verticalBounds(center: cV, screenEdge: screenTopV, isUpperEdge: true)
|
||||||
|
let (bottomBound, lookDownThreshold) = verticalBounds(
|
||||||
|
center: cV,
|
||||||
|
screenEdge: screenBottomV,
|
||||||
|
isUpperEdge: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let allFaceWidths = CalibrationStep.allCases.compactMap { data.averageFaceWidth(for: $0) }
|
||||||
|
let refFaceWidth = allFaceWidths.isEmpty ? 0.0 : allFaceWidths.average()
|
||||||
|
|
||||||
|
let thresholds = GazeThresholds(
|
||||||
|
minLeftRatio: lookLeftThreshold,
|
||||||
|
maxRightRatio: lookRightThreshold,
|
||||||
|
minUpRatio: lookUpThreshold,
|
||||||
|
maxDownRatio: lookDownThreshold,
|
||||||
|
screenLeftBound: leftBound,
|
||||||
|
screenRightBound: rightBound,
|
||||||
|
screenTopBound: topBound,
|
||||||
|
screenBottomBound: bottomBound,
|
||||||
|
referenceFaceWidth: refFaceWidth
|
||||||
|
)
|
||||||
|
|
||||||
|
logThresholds(
|
||||||
|
thresholds: thresholds,
|
||||||
|
centerHorizontal: cH,
|
||||||
|
centerVertical: cV
|
||||||
|
)
|
||||||
|
|
||||||
|
return thresholds
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum HorizontalDirection {
|
||||||
|
case left
|
||||||
|
case right
|
||||||
|
}
|
||||||
|
|
||||||
|
private func horizontalBounds(
|
||||||
|
center: Double,
|
||||||
|
screenEdge: Double?,
|
||||||
|
farEdge: Double?,
|
||||||
|
direction: HorizontalDirection
|
||||||
|
) -> (bound: Double, threshold: Double) {
|
||||||
|
let defaultBoundOffset = direction == .left ? 0.15 : -0.15
|
||||||
|
let defaultThresholdOffset = direction == .left ? 0.20 : -0.20
|
||||||
|
|
||||||
|
guard let screenEdge = screenEdge else {
|
||||||
|
return (center + defaultBoundOffset, center + defaultThresholdOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
let bound = screenEdge
|
||||||
|
let threshold: Double
|
||||||
|
if let farEdge = farEdge {
|
||||||
|
threshold = (screenEdge + farEdge) / 2.0
|
||||||
|
} else {
|
||||||
|
threshold = screenEdge + defaultThresholdOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bound, threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verticalBounds(center: Double, screenEdge: Double?, isUpperEdge: Bool) -> (bound: Double, threshold: Double) {
|
||||||
|
let defaultBoundOffset = isUpperEdge ? -0.10 : 0.10
|
||||||
|
let defaultThresholdOffset = isUpperEdge ? -0.15 : 0.15
|
||||||
|
|
||||||
|
guard let screenEdge = screenEdge else {
|
||||||
|
return (center + defaultBoundOffset, center + defaultThresholdOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
let bound = screenEdge
|
||||||
|
let edgeDistance = isUpperEdge ? center - screenEdge : screenEdge - center
|
||||||
|
let threshold = isUpperEdge ? screenEdge - (edgeDistance * 0.5) : screenEdge + (edgeDistance * 0.5)
|
||||||
|
|
||||||
|
return (bound, threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logThresholds(
|
||||||
|
thresholds: GazeThresholds,
|
||||||
|
centerHorizontal: Double,
|
||||||
|
centerVertical: Double
|
||||||
|
) {
|
||||||
|
print("✓ Calibration thresholds calculated:")
|
||||||
|
print(" Center: H=\(String(format: "%.3f", centerHorizontal)), V=\(String(format: "%.3f", centerVertical))")
|
||||||
|
print(
|
||||||
|
" Screen H-Range: \(String(format: "%.3f", thresholds.screenRightBound)) to \(String(format: "%.3f", thresholds.screenLeftBound))"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Screen V-Range: \(String(format: "%.3f", thresholds.screenTopBound)) to \(String(format: "%.3f", thresholds.screenBottomBound))"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Away Thresholds: L≥\(String(format: "%.3f", thresholds.minLeftRatio)), R≤\(String(format: "%.3f", thresholds.maxRightRatio))"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Away Thresholds: U≤\(String(format: "%.3f", thresholds.minUpRatio)), D≥\(String(format: "%.3f", thresholds.maxDownRatio))"
|
||||||
|
)
|
||||||
|
print(" Ref Face Width: \(String(format: "%.3f", thresholds.referenceFaceWidth))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array where Element == Double {
|
||||||
|
func average() -> Double {
|
||||||
|
guard !isEmpty else { return 0 }
|
||||||
|
return reduce(0.0, +) / Double(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Gaze/Models/DefaultSettingsBuilder.swift
Normal file
39
Gaze/Models/DefaultSettingsBuilder.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// DefaultSettingsBuilder.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/29/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DefaultSettingsBuilder {
|
||||||
|
static let lookAwayEnabled = true
|
||||||
|
static let lookAwayIntervalMinutes = 20
|
||||||
|
static let blinkEnabled = false
|
||||||
|
static let blinkIntervalMinutes = 7
|
||||||
|
static let postureEnabled = true
|
||||||
|
static let postureIntervalMinutes = 30
|
||||||
|
static let subtleReminderSize: ReminderSize = .medium
|
||||||
|
static let smartMode: SmartModeSettings = .defaults
|
||||||
|
static let hasCompletedOnboarding = false
|
||||||
|
static let launchAtLogin = false
|
||||||
|
static let playSounds = true
|
||||||
|
|
||||||
|
static func makeDefaults() -> AppSettings {
|
||||||
|
AppSettings(
|
||||||
|
lookAwayEnabled: lookAwayEnabled,
|
||||||
|
lookAwayIntervalMinutes: lookAwayIntervalMinutes,
|
||||||
|
blinkEnabled: blinkEnabled,
|
||||||
|
blinkIntervalMinutes: blinkIntervalMinutes,
|
||||||
|
postureEnabled: postureEnabled,
|
||||||
|
postureIntervalMinutes: postureIntervalMinutes,
|
||||||
|
userTimers: [],
|
||||||
|
subtleReminderSize: subtleReminderSize,
|
||||||
|
smartMode: smartMode,
|
||||||
|
hasCompletedOnboarding: hasCompletedOnboarding,
|
||||||
|
launchAtLogin: launchAtLogin,
|
||||||
|
playSounds: playSounds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,16 +13,55 @@ struct TimerState: Equatable, Hashable {
|
|||||||
var isPaused: Bool
|
var 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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
84
Gaze/Services/CalibrationFlowController.swift
Normal file
84
Gaze/Services/CalibrationFlowController.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// CalibrationFlowController.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/29/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CalibrationFlowController: ObservableObject {
|
||||||
|
@Published private(set) var currentStep: CalibrationStep?
|
||||||
|
@Published private(set) var currentStepIndex = 0
|
||||||
|
@Published private(set) var isCollectingSamples = false
|
||||||
|
@Published private(set) var samplesCollected = 0
|
||||||
|
|
||||||
|
private let samplesPerStep: Int
|
||||||
|
private let calibrationSteps: [CalibrationStep]
|
||||||
|
|
||||||
|
init(samplesPerStep: Int, calibrationSteps: [CalibrationStep]) {
|
||||||
|
self.samplesPerStep = samplesPerStep
|
||||||
|
self.calibrationSteps = calibrationSteps
|
||||||
|
self.currentStep = calibrationSteps.first
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
isCollectingSamples = false
|
||||||
|
currentStepIndex = 0
|
||||||
|
currentStep = calibrationSteps.first
|
||||||
|
samplesCollected = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
isCollectingSamples = false
|
||||||
|
currentStep = nil
|
||||||
|
currentStepIndex = 0
|
||||||
|
samplesCollected = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func startCollectingSamples() {
|
||||||
|
guard currentStep != nil else { return }
|
||||||
|
isCollectingSamples = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetSamples() {
|
||||||
|
isCollectingSamples = false
|
||||||
|
samplesCollected = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func markSampleCollected() -> Bool {
|
||||||
|
samplesCollected += 1
|
||||||
|
return samplesCollected >= samplesPerStep
|
||||||
|
}
|
||||||
|
|
||||||
|
func advanceToNextStep() -> Bool {
|
||||||
|
isCollectingSamples = false
|
||||||
|
currentStepIndex += 1
|
||||||
|
|
||||||
|
guard currentStepIndex < calibrationSteps.count else {
|
||||||
|
currentStep = nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStep = calibrationSteps[currentStepIndex]
|
||||||
|
samplesCollected = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipStep() -> Bool {
|
||||||
|
advanceToNextStep()
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
let totalSteps = calibrationSteps.count
|
||||||
|
guard totalSteps > 0 else { return 0 }
|
||||||
|
let currentProgress = Double(samplesCollected) / Double(samplesPerStep)
|
||||||
|
return (Double(currentStepIndex) + currentProgress) / Double(totalSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressText: String {
|
||||||
|
"\(min(currentStepIndex + 1, calibrationSteps.count)) of \(calibrationSteps.count)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
// Created by Mike Freno on 1/15/26.
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
Gaze/Services/CalibrationSampleCollector.swift
Normal file
29
Gaze/Services/CalibrationSampleCollector.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// CalibrationSampleCollector.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/29/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CalibrationSampleCollector {
|
||||||
|
mutating func addSample(
|
||||||
|
to data: inout CalibrationData,
|
||||||
|
step: CalibrationStep,
|
||||||
|
leftRatio: Double?,
|
||||||
|
rightRatio: Double?,
|
||||||
|
leftVertical: Double?,
|
||||||
|
rightVertical: Double?,
|
||||||
|
faceWidthRatio: Double?
|
||||||
|
) {
|
||||||
|
let sample = GazeSample(
|
||||||
|
leftRatio: leftRatio,
|
||||||
|
rightRatio: rightRatio,
|
||||||
|
leftVerticalRatio: leftVertical,
|
||||||
|
rightVerticalRatio: rightVertical,
|
||||||
|
faceWidthRatio: faceWidthRatio
|
||||||
|
)
|
||||||
|
data.addSample(sample, for: step)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,11 +69,13 @@ final class EnforceCameraController: ObservableObject {
|
|||||||
|
|
||||||
private func startFaceDetectionTimer() {
|
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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
Gaze/Services/FullscreenWindowMatcher.swift
Normal file
21
Gaze/Services/FullscreenWindowMatcher.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// FullscreenWindowMatcher.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/29/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
struct FullscreenWindowMatcher {
|
||||||
|
func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool {
|
||||||
|
screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool {
|
||||||
|
abs(windowBounds.width - screenFrame.width) < tolerance
|
||||||
|
&& abs(windowBounds.height - screenFrame.height) < tolerance
|
||||||
|
&& abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance
|
||||||
|
&& abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ class ReminderManager: ObservableObject {
|
|||||||
switch type {
|
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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
36
Gaze/Services/TimerConfigurationHelper.swift
Normal file
36
Gaze/Services/TimerConfigurationHelper.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// TimerConfigurationHelper.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/29/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TimerConfigurationHelper {
|
||||||
|
let settingsProvider: any SettingsProviding
|
||||||
|
|
||||||
|
func intervalSeconds(for identifier: TimerIdentifier) -> Int {
|
||||||
|
switch identifier {
|
||||||
|
case .builtIn(let type):
|
||||||
|
return settingsProvider.timerIntervalMinutes(for: type) * 60
|
||||||
|
case .user(let id):
|
||||||
|
guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return userTimer.intervalMinutes * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configurations() -> [TimerIdentifier: TimerConfiguration] {
|
||||||
|
var configurations: [TimerIdentifier: TimerConfiguration] = [:]
|
||||||
|
for timerType in TimerType.allCases {
|
||||||
|
let intervalSeconds = settingsProvider.timerIntervalMinutes(for: timerType) * 60
|
||||||
|
configurations[.builtIn(timerType)] = TimerConfiguration(
|
||||||
|
enabled: settingsProvider.isTimerEnabled(for: timerType),
|
||||||
|
intervalSeconds: intervalSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return configurations
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ class TimerEngine: ObservableObject {
|
|||||||
private let stateManager = TimerStateManager()
|
private let 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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user