feat: smart mode
This commit is contained in:
@@ -20,12 +20,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var hasStartedTimers = false
|
private var hasStartedTimers = false
|
||||||
|
|
||||||
|
// Smart Mode services
|
||||||
|
private var fullscreenService: FullscreenDetectionService?
|
||||||
|
private var idleService: IdleMonitoringService?
|
||||||
|
private var usageTrackingService: UsageTrackingService?
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
// Set activation policy to hide dock icon
|
// Set activation policy to hide dock icon
|
||||||
NSApplication.shared.setActivationPolicy(.accessory)
|
NSApplication.shared.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
timerEngine = TimerEngine(settingsManager: settingsManager)
|
timerEngine = TimerEngine(settingsManager: settingsManager)
|
||||||
|
|
||||||
|
// Initialize Smart Mode services
|
||||||
|
setupSmartModeServices()
|
||||||
|
|
||||||
// Initialize update manager after onboarding is complete
|
// Initialize update manager after onboarding is complete
|
||||||
if settingsManager.settings.hasCompletedOnboarding {
|
if settingsManager.settings.hasCompletedOnboarding {
|
||||||
updateManager = UpdateManager.shared
|
updateManager = UpdateManager.shared
|
||||||
@@ -41,6 +49,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupSmartModeServices() {
|
||||||
|
fullscreenService = FullscreenDetectionService()
|
||||||
|
idleService = IdleMonitoringService(
|
||||||
|
idleThresholdMinutes: settingsManager.settings.smartMode.idleThresholdMinutes
|
||||||
|
)
|
||||||
|
usageTrackingService = UsageTrackingService(
|
||||||
|
resetThresholdMinutes: settingsManager.settings.smartMode.usageResetAfterMinutes
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connect idle service to usage tracking
|
||||||
|
if let idleService = idleService {
|
||||||
|
usageTrackingService?.setupIdleMonitoring(idleService)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect services to timer engine
|
||||||
|
timerEngine?.setupSmartMode(
|
||||||
|
fullscreenService: fullscreenService,
|
||||||
|
idleService: idleService
|
||||||
|
)
|
||||||
|
|
||||||
|
// Observe smart mode settings changes
|
||||||
|
settingsManager.$settings
|
||||||
|
.map { $0.smartMode }
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] smartMode in
|
||||||
|
self?.idleService?.updateThreshold(minutes: smartMode.idleThresholdMinutes)
|
||||||
|
self?.usageTrackingService?.updateResetThreshold(minutes: smartMode.usageResetAfterMinutes)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
func onboardingCompleted() {
|
func onboardingCompleted() {
|
||||||
startTimers()
|
startTimers()
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ struct AppSettings: Codable, Equatable, Hashable {
|
|||||||
var userTimers: [UserTimer]
|
var userTimers: [UserTimer]
|
||||||
|
|
||||||
var subtleReminderSize: ReminderSize
|
var subtleReminderSize: ReminderSize
|
||||||
|
|
||||||
|
var smartMode: SmartModeSettings
|
||||||
|
|
||||||
var hasCompletedOnboarding: Bool
|
var hasCompletedOnboarding: Bool
|
||||||
var launchAtLogin: Bool
|
var launchAtLogin: Bool
|
||||||
@@ -56,6 +58,7 @@ struct AppSettings: Codable, Equatable, Hashable {
|
|||||||
enabled: true, intervalSeconds: 30 * 60),
|
enabled: true, intervalSeconds: 30 * 60),
|
||||||
userTimers: [UserTimer] = [],
|
userTimers: [UserTimer] = [],
|
||||||
subtleReminderSize: ReminderSize = .medium,
|
subtleReminderSize: ReminderSize = .medium,
|
||||||
|
smartMode: SmartModeSettings = .defaults,
|
||||||
hasCompletedOnboarding: Bool = false,
|
hasCompletedOnboarding: Bool = false,
|
||||||
launchAtLogin: Bool = false,
|
launchAtLogin: Bool = false,
|
||||||
playSounds: Bool = true
|
playSounds: Bool = true
|
||||||
@@ -66,6 +69,7 @@ struct AppSettings: Codable, Equatable, Hashable {
|
|||||||
self.postureTimer = postureTimer
|
self.postureTimer = postureTimer
|
||||||
self.userTimers = userTimers
|
self.userTimers = userTimers
|
||||||
self.subtleReminderSize = subtleReminderSize
|
self.subtleReminderSize = subtleReminderSize
|
||||||
|
self.smartMode = smartMode
|
||||||
self.hasCompletedOnboarding = hasCompletedOnboarding
|
self.hasCompletedOnboarding = hasCompletedOnboarding
|
||||||
self.launchAtLogin = launchAtLogin
|
self.launchAtLogin = launchAtLogin
|
||||||
self.playSounds = playSounds
|
self.playSounds = playSounds
|
||||||
@@ -79,6 +83,7 @@ struct AppSettings: Codable, Equatable, Hashable {
|
|||||||
postureTimer: TimerConfiguration(enabled: true, intervalSeconds: 30 * 60),
|
postureTimer: TimerConfiguration(enabled: true, intervalSeconds: 30 * 60),
|
||||||
userTimers: [],
|
userTimers: [],
|
||||||
subtleReminderSize: .medium,
|
subtleReminderSize: .medium,
|
||||||
|
smartMode: .defaults,
|
||||||
hasCompletedOnboarding: false,
|
hasCompletedOnboarding: false,
|
||||||
launchAtLogin: false,
|
launchAtLogin: false,
|
||||||
playSounds: true
|
playSounds: true
|
||||||
|
|||||||
15
Gaze/Models/PauseReason.swift
Normal file
15
Gaze/Models/PauseReason.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// PauseReason.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/14/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PauseReason: Codable, Equatable, Hashable {
|
||||||
|
case manual
|
||||||
|
case fullscreen
|
||||||
|
case idle
|
||||||
|
case system
|
||||||
|
}
|
||||||
40
Gaze/Models/SmartModeSettings.swift
Normal file
40
Gaze/Models/SmartModeSettings.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// SmartModeSettings.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/14/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SmartModeSettings: Codable, Equatable, Hashable {
|
||||||
|
var autoPauseOnFullscreen: Bool
|
||||||
|
var autoPauseOnIdle: Bool
|
||||||
|
var trackUsage: Bool
|
||||||
|
var idleThresholdMinutes: Int
|
||||||
|
var usageResetAfterMinutes: Int
|
||||||
|
|
||||||
|
init(
|
||||||
|
autoPauseOnFullscreen: Bool = false,
|
||||||
|
autoPauseOnIdle: Bool = false,
|
||||||
|
trackUsage: Bool = false,
|
||||||
|
idleThresholdMinutes: Int = 5,
|
||||||
|
usageResetAfterMinutes: Int = 60
|
||||||
|
) {
|
||||||
|
self.autoPauseOnFullscreen = autoPauseOnFullscreen
|
||||||
|
self.autoPauseOnIdle = autoPauseOnIdle
|
||||||
|
self.trackUsage = trackUsage
|
||||||
|
self.idleThresholdMinutes = idleThresholdMinutes
|
||||||
|
self.usageResetAfterMinutes = usageResetAfterMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
static var defaults: SmartModeSettings {
|
||||||
|
SmartModeSettings(
|
||||||
|
autoPauseOnFullscreen: false,
|
||||||
|
autoPauseOnIdle: false,
|
||||||
|
trackUsage: false,
|
||||||
|
idleThresholdMinutes: 5,
|
||||||
|
usageResetAfterMinutes: 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,16 @@ struct TimerState: Equatable, Hashable {
|
|||||||
let identifier: TimerIdentifier
|
let identifier: TimerIdentifier
|
||||||
var remainingSeconds: Int
|
var remainingSeconds: Int
|
||||||
var isPaused: Bool
|
var isPaused: Bool
|
||||||
|
var pauseReasons: Set<PauseReason>
|
||||||
var isActive: Bool
|
var isActive: Bool
|
||||||
var targetDate: Date
|
var targetDate: Date
|
||||||
let originalIntervalSeconds: Int // Store original interval for comparison
|
let originalIntervalSeconds: Int
|
||||||
|
|
||||||
init(identifier: TimerIdentifier, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) {
|
init(identifier: TimerIdentifier, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) {
|
||||||
self.identifier = identifier
|
self.identifier = identifier
|
||||||
self.remainingSeconds = intervalSeconds
|
self.remainingSeconds = intervalSeconds
|
||||||
self.isPaused = isPaused
|
self.isPaused = isPaused
|
||||||
|
self.pauseReasons = []
|
||||||
self.isActive = isActive
|
self.isActive = isActive
|
||||||
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
|
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
|
||||||
self.originalIntervalSeconds = intervalSeconds
|
self.originalIntervalSeconds = intervalSeconds
|
||||||
|
|||||||
102
Gaze/Services/FullscreenDetectionService.swift
Normal file
102
Gaze/Services/FullscreenDetectionService.swift
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// FullscreenDetectionService.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/14/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class FullscreenDetectionService: ObservableObject {
|
||||||
|
@Published private(set) var isFullscreenActive = false
|
||||||
|
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
setupObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
let notificationCenter = NSWorkspace.shared.notificationCenter
|
||||||
|
observers.forEach { notificationCenter.removeObserver($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
let workspace = NSWorkspace.shared
|
||||||
|
let notificationCenter = workspace.notificationCenter
|
||||||
|
|
||||||
|
// Monitor when applications enter fullscreen
|
||||||
|
let didEnterObserver = notificationCenter.addObserver(
|
||||||
|
forName: NSWorkspace.activeSpaceDidChangeNotification,
|
||||||
|
object: workspace,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.checkFullscreenState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observers.append(didEnterObserver)
|
||||||
|
|
||||||
|
// Monitor when active application changes
|
||||||
|
let didActivateObserver = notificationCenter.addObserver(
|
||||||
|
forName: NSWorkspace.didActivateApplicationNotification,
|
||||||
|
object: workspace,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.checkFullscreenState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observers.append(didActivateObserver)
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkFullscreenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkFullscreenState() {
|
||||||
|
guard let frontmostApp = NSWorkspace.shared.frontmostApplication else {
|
||||||
|
isFullscreenActive = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any window of the frontmost application is fullscreen
|
||||||
|
let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements)
|
||||||
|
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
||||||
|
|
||||||
|
let frontmostPID = frontmostApp.processIdentifier
|
||||||
|
|
||||||
|
for window in windowList {
|
||||||
|
guard let ownerPID = window[kCGWindowOwnerPID as String] as? pid_t,
|
||||||
|
ownerPID == frontmostPID,
|
||||||
|
let bounds = window[kCGWindowBounds as String] as? [String: CGFloat],
|
||||||
|
let layer = window[kCGWindowLayer as String] as? Int else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window is fullscreen by comparing bounds to screen size
|
||||||
|
if let screen = NSScreen.main {
|
||||||
|
let screenFrame = screen.frame
|
||||||
|
let windowWidth = bounds["Width"] ?? 0
|
||||||
|
let windowHeight = bounds["Height"] ?? 0
|
||||||
|
|
||||||
|
// Window is considered fullscreen if it matches screen dimensions
|
||||||
|
// and is at a normal window layer (0)
|
||||||
|
if layer == 0 &&
|
||||||
|
abs(windowWidth - screenFrame.width) < 1 &&
|
||||||
|
abs(windowHeight - screenFrame.height) < 1 {
|
||||||
|
isFullscreenActive = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isFullscreenActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceUpdate() {
|
||||||
|
checkFullscreenState()
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Gaze/Services/IdleMonitoringService.swift
Normal file
67
Gaze/Services/IdleMonitoringService.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// IdleMonitoringService.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/14/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class IdleMonitoringService: ObservableObject {
|
||||||
|
@Published private(set) var isIdle = false
|
||||||
|
@Published private(set) var idleTimeSeconds: TimeInterval = 0
|
||||||
|
|
||||||
|
private var timer: Timer?
|
||||||
|
private var idleThresholdSeconds: TimeInterval
|
||||||
|
|
||||||
|
init(idleThresholdMinutes: Int = 5) {
|
||||||
|
self.idleThresholdSeconds = TimeInterval(idleThresholdMinutes * 60)
|
||||||
|
startMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateThreshold(minutes: Int) {
|
||||||
|
idleThresholdSeconds = TimeInterval(minutes * 60)
|
||||||
|
checkIdleState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startMonitoring() {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.checkIdleState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkIdleState() {
|
||||||
|
idleTimeSeconds = CGEventSource.secondsSinceLastEventType(
|
||||||
|
.combinedSessionState,
|
||||||
|
eventType: .mouseMoved
|
||||||
|
)
|
||||||
|
|
||||||
|
// Also check keyboard events and use the minimum
|
||||||
|
let keyboardIdleTime = CGEventSource.secondsSinceLastEventType(
|
||||||
|
.combinedSessionState,
|
||||||
|
eventType: .keyDown
|
||||||
|
)
|
||||||
|
|
||||||
|
idleTimeSeconds = min(idleTimeSeconds, keyboardIdleTime)
|
||||||
|
|
||||||
|
let wasIdle = isIdle
|
||||||
|
isIdle = idleTimeSeconds >= idleThresholdSeconds
|
||||||
|
|
||||||
|
if wasIdle != isIdle {
|
||||||
|
print("🔄 Idle state changed: \(isIdle ? "IDLE" : "ACTIVE") (idle: \(Int(idleTimeSeconds))s, threshold: \(Int(idleThresholdSeconds))s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceUpdate() {
|
||||||
|
checkIdleState()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ class TimerEngine: ObservableObject {
|
|||||||
|
|
||||||
// For enforce mode integration
|
// For enforce mode integration
|
||||||
private var enforceModeService: EnforceModeService?
|
private var enforceModeService: EnforceModeService?
|
||||||
|
|
||||||
|
// Smart Mode services
|
||||||
|
private var fullscreenService: FullscreenDetectionService?
|
||||||
|
private var idleService: IdleMonitoringService?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(settingsManager: SettingsManager) {
|
init(settingsManager: SettingsManager) {
|
||||||
self.settingsManager = settingsManager
|
self.settingsManager = settingsManager
|
||||||
@@ -28,6 +33,72 @@ class TimerEngine: ObservableObject {
|
|||||||
self.enforceModeService?.setTimerEngine(self)
|
self.enforceModeService?.setTimerEngine(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupSmartMode(
|
||||||
|
fullscreenService: FullscreenDetectionService?,
|
||||||
|
idleService: IdleMonitoringService?
|
||||||
|
) {
|
||||||
|
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 settingsManager.settings.smartMode.autoPauseOnFullscreen else { return }
|
||||||
|
|
||||||
|
if isFullscreen {
|
||||||
|
pauseAllTimers(reason: .fullscreen)
|
||||||
|
print("⏸️ Timers paused: fullscreen detected")
|
||||||
|
} else {
|
||||||
|
resumeAllTimers(reason: .fullscreen)
|
||||||
|
print("▶️ Timers resumed: fullscreen exited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleIdleChange(isIdle: Bool) {
|
||||||
|
guard settingsManager.settings.smartMode.autoPauseOnIdle else { return }
|
||||||
|
|
||||||
|
if isIdle {
|
||||||
|
pauseAllTimers(reason: .idle)
|
||||||
|
print("⏸️ Timers paused: user idle")
|
||||||
|
} else {
|
||||||
|
resumeAllTimers(reason: .idle)
|
||||||
|
print("▶️ Timers resumed: user active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pauseAllTimers(reason: PauseReason) {
|
||||||
|
for (id, var state) in timerStates {
|
||||||
|
state.pauseReasons.insert(reason)
|
||||||
|
state.isPaused = true
|
||||||
|
timerStates[id] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumeAllTimers(reason: PauseReason) {
|
||||||
|
for (id, var state) in timerStates {
|
||||||
|
state.pauseReasons.remove(reason)
|
||||||
|
state.isPaused = !state.pauseReasons.isEmpty
|
||||||
|
timerStates[id] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
// If timers are already running, just update configurations without resetting
|
// If timers are already running, just update configurations without resetting
|
||||||
@@ -163,23 +234,33 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
for (id, _) in timerStates {
|
for (id, var state) in timerStates {
|
||||||
timerStates[id]?.isPaused = true
|
state.pauseReasons.insert(.manual)
|
||||||
|
state.isPaused = true
|
||||||
|
timerStates[id] = state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resume() {
|
func resume() {
|
||||||
for (id, _) in timerStates {
|
for (id, var state) in timerStates {
|
||||||
timerStates[id]?.isPaused = false
|
state.pauseReasons.remove(.manual)
|
||||||
|
state.isPaused = !state.pauseReasons.isEmpty
|
||||||
|
timerStates[id] = state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pauseTimer(identifier: TimerIdentifier) {
|
func pauseTimer(identifier: TimerIdentifier) {
|
||||||
timerStates[identifier]?.isPaused = true
|
guard var state = timerStates[identifier] else { return }
|
||||||
|
state.pauseReasons.insert(.manual)
|
||||||
|
state.isPaused = true
|
||||||
|
timerStates[identifier] = state
|
||||||
}
|
}
|
||||||
|
|
||||||
func resumeTimer(identifier: TimerIdentifier) {
|
func resumeTimer(identifier: TimerIdentifier) {
|
||||||
timerStates[identifier]?.isPaused = false
|
guard var state = timerStates[identifier] else { return }
|
||||||
|
state.pauseReasons.remove(.manual)
|
||||||
|
state.isPaused = !state.pauseReasons.isEmpty
|
||||||
|
timerStates[identifier] = state
|
||||||
}
|
}
|
||||||
|
|
||||||
func skipNext(identifier: TimerIdentifier) {
|
func skipNext(identifier: TimerIdentifier) {
|
||||||
@@ -285,7 +366,11 @@ class TimerEngine: ObservableObject {
|
|||||||
/// - Pauses all active timers
|
/// - Pauses all active timers
|
||||||
func handleSystemSleep() {
|
func handleSystemSleep() {
|
||||||
sleepStartTime = Date()
|
sleepStartTime = Date()
|
||||||
pause()
|
for (id, var state) in timerStates {
|
||||||
|
state.pauseReasons.insert(.system)
|
||||||
|
state.isPaused = true
|
||||||
|
timerStates[id] = state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles system wake event
|
/// Handles system wake event
|
||||||
@@ -305,11 +390,15 @@ class TimerEngine: ObservableObject {
|
|||||||
let elapsedSeconds = Int(Date().timeIntervalSince(sleepStart))
|
let elapsedSeconds = Int(Date().timeIntervalSince(sleepStart))
|
||||||
|
|
||||||
guard elapsedSeconds >= 1 else {
|
guard elapsedSeconds >= 1 else {
|
||||||
resume()
|
for (id, var state) in timerStates {
|
||||||
|
state.pauseReasons.remove(.system)
|
||||||
|
state.isPaused = !state.pauseReasons.isEmpty
|
||||||
|
timerStates[id] = state
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (identifier, state) in timerStates where state.isActive && !state.isPaused {
|
for (identifier, state) in timerStates where state.isActive {
|
||||||
var updatedState = state
|
var updatedState = state
|
||||||
updatedState.remainingSeconds = max(0, state.remainingSeconds - elapsedSeconds)
|
updatedState.remainingSeconds = max(0, state.remainingSeconds - elapsedSeconds)
|
||||||
|
|
||||||
@@ -317,9 +406,9 @@ class TimerEngine: ObservableObject {
|
|||||||
updatedState.remainingSeconds = 1
|
updatedState.remainingSeconds = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatedState.pauseReasons.remove(.system)
|
||||||
|
updatedState.isPaused = !updatedState.pauseReasons.isEmpty
|
||||||
timerStates[identifier] = updatedState
|
timerStates[identifier] = updatedState
|
||||||
}
|
}
|
||||||
|
|
||||||
resume()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
170
Gaze/Services/UsageTrackingService.swift
Normal file
170
Gaze/Services/UsageTrackingService.swift
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
//
|
||||||
|
// UsageTrackingService.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/14/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct UsageStatistics: Codable {
|
||||||
|
var totalActiveSeconds: TimeInterval
|
||||||
|
var totalIdleSeconds: TimeInterval
|
||||||
|
var lastResetDate: Date
|
||||||
|
var sessionStartDate: Date
|
||||||
|
|
||||||
|
var totalActiveMinutes: Int {
|
||||||
|
Int(totalActiveSeconds / 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalIdleMinutes: Int {
|
||||||
|
Int(totalIdleSeconds / 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class UsageTrackingService: ObservableObject {
|
||||||
|
@Published private(set) var statistics: UsageStatistics
|
||||||
|
|
||||||
|
private var lastUpdateTime: Date
|
||||||
|
private var wasIdle: Bool = false
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let statisticsKey = "gazeUsageStatistics"
|
||||||
|
private var resetThresholdMinutes: Int
|
||||||
|
|
||||||
|
private var idleService: IdleMonitoringService?
|
||||||
|
|
||||||
|
init(resetThresholdMinutes: Int = 60) {
|
||||||
|
self.resetThresholdMinutes = resetThresholdMinutes
|
||||||
|
self.lastUpdateTime = Date()
|
||||||
|
|
||||||
|
if let data = userDefaults.data(forKey: statisticsKey),
|
||||||
|
let decoded = try? JSONDecoder().decode(UsageStatistics.self, from: data) {
|
||||||
|
self.statistics = decoded
|
||||||
|
} else {
|
||||||
|
self.statistics = UsageStatistics(
|
||||||
|
totalActiveSeconds: 0,
|
||||||
|
totalIdleSeconds: 0,
|
||||||
|
lastResetDate: Date(),
|
||||||
|
sessionStartDate: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkForReset()
|
||||||
|
startTracking()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupIdleMonitoring(_ idleService: IdleMonitoringService) {
|
||||||
|
self.idleService = idleService
|
||||||
|
|
||||||
|
idleService.$isIdle
|
||||||
|
.sink { [weak self] isIdle in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.updateTracking(isIdle: isIdle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateResetThreshold(minutes: Int) {
|
||||||
|
resetThresholdMinutes = minutes
|
||||||
|
checkForReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTracking() {
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tick() {
|
||||||
|
let now = Date()
|
||||||
|
let elapsed = now.timeIntervalSince(lastUpdateTime)
|
||||||
|
lastUpdateTime = now
|
||||||
|
|
||||||
|
let isCurrentlyIdle = idleService?.isIdle ?? false
|
||||||
|
|
||||||
|
if isCurrentlyIdle {
|
||||||
|
statistics.totalIdleSeconds += elapsed
|
||||||
|
} else {
|
||||||
|
statistics.totalActiveSeconds += elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
wasIdle = isCurrentlyIdle
|
||||||
|
|
||||||
|
checkForReset()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTracking(isIdle: Bool) {
|
||||||
|
let now = Date()
|
||||||
|
let elapsed = now.timeIntervalSince(lastUpdateTime)
|
||||||
|
|
||||||
|
if wasIdle {
|
||||||
|
statistics.totalIdleSeconds += elapsed
|
||||||
|
} else {
|
||||||
|
statistics.totalActiveSeconds += elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdateTime = now
|
||||||
|
wasIdle = isIdle
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkForReset() {
|
||||||
|
let totalMinutes = statistics.totalActiveMinutes + statistics.totalIdleMinutes
|
||||||
|
|
||||||
|
if totalMinutes >= resetThresholdMinutes {
|
||||||
|
reset()
|
||||||
|
print("♻️ Usage statistics reset after \(totalMinutes) minutes (threshold: \(resetThresholdMinutes))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
statistics = UsageStatistics(
|
||||||
|
totalActiveSeconds: 0,
|
||||||
|
totalIdleSeconds: 0,
|
||||||
|
lastResetDate: Date(),
|
||||||
|
sessionStartDate: Date()
|
||||||
|
)
|
||||||
|
lastUpdateTime = Date()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
if let encoded = try? JSONEncoder().encode(statistics) {
|
||||||
|
userDefaults.set(encoded, forKey: statisticsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFormattedActiveTime() -> String {
|
||||||
|
formatDuration(seconds: Int(statistics.totalActiveSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFormattedIdleTime() -> String {
|
||||||
|
formatDuration(seconds: Int(statistics.totalIdleSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFormattedTotalTime() -> String {
|
||||||
|
let total = Int(statistics.totalActiveSeconds + statistics.totalIdleSeconds)
|
||||||
|
return formatDuration(seconds: total)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDuration(seconds: Int) -> String {
|
||||||
|
let hours = seconds / 3600
|
||||||
|
let minutes = (seconds % 3600) / 60
|
||||||
|
let secs = seconds % 60
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
return String(format: "%dh %dm %ds", hours, minutes, secs)
|
||||||
|
} else if minutes > 0 {
|
||||||
|
return String(format: "%dm %ds", minutes, secs)
|
||||||
|
} else {
|
||||||
|
return String(format: "%ds", secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,11 +54,17 @@ struct SettingsWindowView: View {
|
|||||||
Label("User Timers", systemImage: "plus.circle")
|
Label("User Timers", systemImage: "plus.circle")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SmartModeSetupView(settingsManager: settingsManager)
|
||||||
|
.tag(5)
|
||||||
|
.tabItem {
|
||||||
|
Label("Smart Mode", systemImage: "brain.fill")
|
||||||
|
}
|
||||||
|
|
||||||
GeneralSetupView(
|
GeneralSetupView(
|
||||||
settingsManager: settingsManager,
|
settingsManager: settingsManager,
|
||||||
isOnboarding: false
|
isOnboarding: false
|
||||||
)
|
)
|
||||||
.tag(5)
|
.tag(6)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("General", systemImage: "gearshape.fill")
|
Label("General", systemImage: "gearshape.fill")
|
||||||
}
|
}
|
||||||
|
|||||||
164
Gaze/Views/Setup/SmartModeSetupView.swift
Normal file
164
Gaze/Views/Setup/SmartModeSetupView.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
//
|
||||||
|
// SmartModeSetupView.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/14/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SmartModeSetupView: View {
|
||||||
|
@ObservedObject var settingsManager: SettingsManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Fixed header section
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "brain.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
|
||||||
|
Text("Smart Mode")
|
||||||
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
|
||||||
|
Text("Automatically manage timers based on your activity")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
|
// Vertically centered content
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Auto-pause on fullscreen toggle
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { settingsManager.settings.smartMode.autoPauseOnFullscreen },
|
||||||
|
set: { settingsManager.settings.smartMode.autoPauseOnFullscreen = $0 }
|
||||||
|
)) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text("Auto-pause on Fullscreen")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.leading, 28)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
|
||||||
|
|
||||||
|
// Auto-pause on idle toggle with threshold slider
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { settingsManager.settings.smartMode.autoPauseOnIdle },
|
||||||
|
set: { settingsManager.settings.smartMode.autoPauseOnIdle = $0 }
|
||||||
|
)) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "moon.zzz.fill")
|
||||||
|
.foregroundColor(.indigo)
|
||||||
|
Text("Auto-pause on Idle")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
Text("Timers will pause when you're inactive for more than the threshold below")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.leading, 28)
|
||||||
|
|
||||||
|
if settingsManager.settings.smartMode.autoPauseOnIdle {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Idle Threshold:")
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(settingsManager.settings.smartMode.idleThresholdMinutes) min")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(settingsManager.settings.smartMode.idleThresholdMinutes) },
|
||||||
|
set: { settingsManager.settings.smartMode.idleThresholdMinutes = Int($0) }
|
||||||
|
),
|
||||||
|
in: 1...30,
|
||||||
|
step: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.leading, 28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
|
||||||
|
|
||||||
|
// Usage tracking toggle with reset threshold
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { settingsManager.settings.smartMode.trackUsage },
|
||||||
|
set: { settingsManager.settings.smartMode.trackUsage = $0 }
|
||||||
|
)) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text("Track Usage Statistics")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
Text("Monitor active and idle time, with automatic reset after the specified duration")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.leading, 28)
|
||||||
|
|
||||||
|
if settingsManager.settings.smartMode.trackUsage {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Reset After:")
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(settingsManager.settings.smartMode.usageResetAfterMinutes) min")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(settingsManager.settings.smartMode.usageResetAfterMinutes) },
|
||||||
|
set: { settingsManager.settings.smartMode.usageResetAfterMinutes = Int($0) }
|
||||||
|
),
|
||||||
|
in: 15...240,
|
||||||
|
step: 15
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.leading, 28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 600)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SmartModeSetupView(settingsManager: SettingsManager.shared)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user