checkpoint

This commit is contained in:
Michael Freno
2026-01-27 13:36:59 -05:00
parent a5602d9829
commit fda136f3d4
6 changed files with 467 additions and 51 deletions

View File

@@ -0,0 +1,64 @@
//
// ReminderManager.swift
// Gaze
//
// Manages reminder triggering and dismissal logic for timers.
//
import Combine
import Foundation
@MainActor
class ReminderManager: ObservableObject {
@Published var activeReminder: ReminderEvent?
private let settingsProvider: any SettingsProviding
private var enforceModeService: EnforceModeService?
private var timerEngine: TimerEngine?
init(
settingsProvider: any SettingsProviding,
enforceModeService: EnforceModeService? = nil
) {
self.settingsProvider = settingsProvider
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
}
func setTimerEngine(_ engine: TimerEngine) {
self.timerEngine = engine
}
func triggerReminder(for identifier: TimerIdentifier) {
// Pause only the timer that triggered
timerEngine?.pauseTimer(identifier: identifier)
// Unified approach to handle all timer types - no more special handling
switch identifier {
case .builtIn(let type):
switch type {
case .lookAway:
activeReminder = .lookAwayTriggered(
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds)
case .blink:
activeReminder = .blinkTriggered
case .posture:
activeReminder = .postureTriggered
}
case .user(let id):
if let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) {
activeReminder = .userTimerTriggered(userTimer)
}
}
}
func dismissReminder() {
guard let reminder = activeReminder else { return }
activeReminder = nil
let identifier = reminder.identifier
timerEngine?.skipNext(identifier: identifier)
timerEngine?.resumeTimer(identifier: identifier)
enforceModeService?.handleReminderDismissed()
}
}

View File

@@ -64,7 +64,8 @@ final class ServiceContainer {
}
let engine = TimerEngine(
settingsManager: settingsManager,
enforceModeService: enforceModeService
enforceModeService: enforceModeService,
timeProvider: isTestEnvironment ? MockTimeProvider() : SystemTimeProvider()
)
_timerEngine = engine
return engine

View File

@@ -0,0 +1,72 @@
//
// SmartModeManager.swift
// Gaze
//
// Handles smart mode features like idle detection and fullscreen detection.
//
import Combine
import Foundation
@MainActor
class SmartModeManager {
private var fullscreenService: FullscreenDetectionService?
private var idleService: IdleMonitoringService?
private var timerEngine: TimerEngine?
private var cancellables = Set<AnyCancellable>()
func setupSmartMode(
timerEngine: TimerEngine,
fullscreenService: FullscreenDetectionService?,
idleService: IdleMonitoringService?
) {
self.timerEngine = timerEngine
self.fullscreenService = fullscreenService
self.idleService = idleService
// Subscribe to fullscreen state changes
fullscreenService?.$isFullscreenActive
.sink { [weak self] isFullscreen in
Task { @MainActor in
self?.handleFullscreenChange(isFullscreen: isFullscreen)
}
}
.store(in: &cancellables)
// Subscribe to idle state changes
idleService?.$isIdle
.sink { [weak self] isIdle in
Task { @MainActor in
self?.handleIdleChange(isIdle: isIdle)
}
}
.store(in: &cancellables)
}
private func handleFullscreenChange(isFullscreen: Bool) {
guard let timerEngine = timerEngine else { return }
guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnFullscreen else { return }
if isFullscreen {
timerEngine.pauseAllTimers(reason: .fullscreen)
logInfo("⏸️ Timers paused: fullscreen detected")
} else {
timerEngine.resumeAllTimers(reason: .fullscreen)
logInfo("▶️ Timers resumed: fullscreen exited")
}
}
private func handleIdleChange(isIdle: Bool) {
guard let timerEngine = timerEngine else { return }
guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnIdle else { return }
if isIdle {
timerEngine.pauseAllTimers(reason: .idle)
logInfo("⏸️ Timers paused: user idle")
} else {
timerEngine.resumeAllTimers(reason: .idle)
logInfo("▶️ Timers resumed: user active")
}
}
}

View File

@@ -15,6 +15,11 @@ class TimerEngine: ObservableObject {
private var timerSubscription: AnyCancellable?
private let settingsProvider: any SettingsProviding
// Expose the settings provider for components that need it (like SmartModeManager)
var settingsProviderForTesting: any SettingsProviding {
return settingsProvider
}
private var sleepStartTime: Date?
/// Time provider for deterministic testing (defaults to system time)
@@ -103,7 +108,7 @@ class TimerEngine: ObservableObject {
}
}
private func pauseAllTimers(reason: PauseReason) {
func pauseAllTimers(reason: PauseReason) {
for (id, var state) in timerStates {
state.pauseReasons.insert(reason)
state.isPaused = true
@@ -111,7 +116,7 @@ class TimerEngine: ObservableObject {
}
}
private func resumeAllTimers(reason: PauseReason) {
func resumeAllTimers(reason: PauseReason) {
for (id, var state) in timerStates {
state.pauseReasons.remove(reason)
state.isPaused = !state.pauseReasons.isEmpty
@@ -394,57 +399,20 @@ func skipNext(identifier: TimerIdentifier) {
return timerStates[identifier]?.isPaused ?? true
}
/// Handles system sleep event
/// - Saves current time for elapsed calculation
/// - Pauses all active timers
// System sleep/wake handling is now managed by SystemSleepManager
// This method is kept for compatibility but will be removed in future versions
/// Handles system sleep event - deprecated
@available(*, deprecated, message: "Use SystemSleepManager instead")
func handleSystemSleep() {
logDebug("System going to sleep")
sleepStartTime = timeProvider.now()
for (id, var state) in timerStates {
state.pauseReasons.insert(.system)
state.isPaused = true
timerStates[id] = state
}
logDebug("System going to sleep (deprecated)")
// This functionality has been moved to SystemSleepManager
}
/// Handles system wake event
/// - Calculates elapsed time during sleep
/// - Adjusts remaining time for all active timers
/// - Timers that expired during sleep will trigger immediately (1s delay)
/// - Resumes all timers
/// Handles system wake event - deprecated
@available(*, deprecated, message: "Use SystemSleepManager instead")
func handleSystemWake() {
logDebug("System waking up")
guard let sleepStart = sleepStartTime else {
return
}
defer {
sleepStartTime = nil
}
let elapsedSeconds = Int(timeProvider.now().timeIntervalSince(sleepStart))
guard elapsedSeconds >= 1 else {
for (id, var state) in timerStates {
state.pauseReasons.remove(.system)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[id] = state
}
return
}
for (identifier, state) in timerStates where state.isActive {
var updatedState = state
updatedState.remainingSeconds = max(0, state.remainingSeconds - elapsedSeconds)
if updatedState.remainingSeconds <= 0 {
updatedState.remainingSeconds = 1
}
updatedState.pauseReasons.remove(.system)
updatedState.isPaused = !updatedState.pauseReasons.isEmpty
timerStates[identifier] = updatedState
}
logDebug("System waking up (deprecated)")
// This functionality has been moved to SystemSleepManager
}
}

View File

@@ -0,0 +1,248 @@
//
// TimerManager.swift
// Gaze
//
// Manages timer creation, state updates, and lifecycle operations.
//
import Combine
import Foundation
@MainActor
class TimerManager: ObservableObject {
@Published var timerStates: [TimerIdentifier: TimerState] = [:]
private let settingsProvider: any SettingsProviding
private var timerSubscription: AnyCancellable?
private let timeProvider: TimeProviding
init(
settingsManager: any SettingsProviding,
timeProvider: TimeProviding
) {
self.settingsProvider = settingsManager
self.timeProvider = timeProvider
}
func start() {
// If timers are already running, just update configurations without resetting
if timerSubscription != nil {
updateConfigurations()
return
}
// Initial start - create all timer states
stop()
var newStates: [TimerIdentifier: TimerState] = [:]
// Add built-in timers (using unified approach)
for timerType in TimerType.allCases {
let config = settingsProvider.timerConfiguration(for: timerType)
if config.enabled {
let identifier = TimerIdentifier.builtIn(timerType)
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
)
}
}
// Add user timers (using unified approach)
for userTimer in settingsProvider.settings.userTimers where userTimer.enabled {
let identifier = TimerIdentifier.user(id: userTimer.id)
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: userTimer.intervalMinutes * 60,
isPaused: false,
isActive: true
)
}
// Assign the entire dictionary at once to trigger @Published
timerStates = newStates
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
Task { @MainActor in
self?.handleTick()
}
}
}
func stop() {
timerSubscription?.cancel()
timerSubscription = nil
timerStates.removeAll()
}
func updateConfigurations() {
// Update configurations from settings
var newStates: [TimerIdentifier: TimerState] = [:]
// Update built-in timers (using unified approach)
for timerType in TimerType.allCases {
let config = settingsProvider.timerConfiguration(for: timerType)
let identifier = TimerIdentifier.builtIn(timerType)
if config.enabled {
if let existingState = timerStates[identifier] {
// Timer exists - check if interval changed
if existingState.originalIntervalSeconds != config.intervalSeconds {
// Interval changed - reset with new interval
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
// Interval unchanged - keep existing state
newStates[identifier] = existingState
}
} else {
// Timer was just enabled - create new state
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
)
}
}
// If config.enabled is false and timer exists, it will be removed
}
// Update user timers (using unified approach)
for userTimer in settingsProvider.settings.userTimers {
let identifier = TimerIdentifier.user(id: userTimer.id)
let newIntervalSeconds = userTimer.intervalMinutes * 60
if userTimer.enabled {
if let existingState = timerStates[identifier] {
// Check if interval changed
if existingState.originalIntervalSeconds != newIntervalSeconds {
// Interval changed - reset with new interval
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
// Interval unchanged - keep existing state
newStates[identifier] = existingState
}
} else {
// New timer - create state
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: false,
isActive: true
)
}
}
// If timer is disabled, it will be removed
}
// Assign the entire dictionary at once to trigger @Published
timerStates = newStates
}
private func handleTick() {
for (identifier, state) in timerStates {
guard !state.isPaused else { continue }
guard state.isActive else { continue }
if state.targetDate < timeProvider.now() - 3.0 {
// Timer has expired but with some grace period
continue
}
timerStates[identifier]?.remainingSeconds -= 1
if let updatedState = timerStates[identifier] {
// Update remaining seconds for the timer
if updatedState.remainingSeconds <= 0 {
// This would normally trigger a reminder in a full implementation,
// but we're decomposing it to separate components
}
}
}
}
func pause() {
for (id, var state) in timerStates {
state.pauseReasons.insert(.manual)
state.isPaused = true
timerStates[id] = state
}
}
func resume() {
for (id, var state) in timerStates {
state.pauseReasons.remove(.manual)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[id] = state
}
}
func pauseTimer(identifier: TimerIdentifier) {
guard var state = timerStates[identifier] else { return }
state.pauseReasons.insert(.manual)
state.isPaused = true
timerStates[identifier] = state
}
func resumeTimer(identifier: TimerIdentifier) {
guard var state = timerStates[identifier] else { return }
state.pauseReasons.remove(.manual)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[identifier] = state
}
func skipNext(identifier: TimerIdentifier) {
guard let state = timerStates[identifier] else { return }
// Unified approach to get interval - no more separate handling for user timers
let intervalSeconds = getTimerInterval(for: identifier)
timerStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: intervalSeconds,
isPaused: state.isPaused,
isActive: state.isActive
)
}
/// Unified way to get interval for any timer type
private func getTimerInterval(for identifier: TimerIdentifier) -> Int {
switch identifier {
case .builtIn(let type):
let config = settingsProvider.timerConfiguration(for: type)
return config.intervalSeconds
case .user(let id):
guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else {
return 0
}
return userTimer.intervalMinutes * 60
}
}
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
guard let state = timerStates[identifier] else { return 0 }
return TimeInterval(state.remainingSeconds)
}
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {
return getTimeRemaining(for: identifier).formatAsTimerDurationFull()
}
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
return timerStates[identifier]?.isPaused ?? true
}
}