feat: smart mode
This commit is contained in:
@@ -20,12 +20,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var hasStartedTimers = false
|
||||
|
||||
// Smart Mode services
|
||||
private var fullscreenService: FullscreenDetectionService?
|
||||
private var idleService: IdleMonitoringService?
|
||||
private var usageTrackingService: UsageTrackingService?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// Set activation policy to hide dock icon
|
||||
NSApplication.shared.setActivationPolicy(.accessory)
|
||||
|
||||
timerEngine = TimerEngine(settingsManager: settingsManager)
|
||||
|
||||
// Initialize Smart Mode services
|
||||
setupSmartModeServices()
|
||||
|
||||
// Initialize update manager after onboarding is complete
|
||||
if settingsManager.settings.hasCompletedOnboarding {
|
||||
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() {
|
||||
startTimers()
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ struct AppSettings: Codable, Equatable, Hashable {
|
||||
var userTimers: [UserTimer]
|
||||
|
||||
var subtleReminderSize: ReminderSize
|
||||
|
||||
var smartMode: SmartModeSettings
|
||||
|
||||
var hasCompletedOnboarding: Bool
|
||||
var launchAtLogin: Bool
|
||||
@@ -56,6 +58,7 @@ struct AppSettings: Codable, Equatable, Hashable {
|
||||
enabled: true, intervalSeconds: 30 * 60),
|
||||
userTimers: [UserTimer] = [],
|
||||
subtleReminderSize: ReminderSize = .medium,
|
||||
smartMode: SmartModeSettings = .defaults,
|
||||
hasCompletedOnboarding: Bool = false,
|
||||
launchAtLogin: Bool = false,
|
||||
playSounds: Bool = true
|
||||
@@ -66,6 +69,7 @@ struct AppSettings: Codable, Equatable, Hashable {
|
||||
self.postureTimer = postureTimer
|
||||
self.userTimers = userTimers
|
||||
self.subtleReminderSize = subtleReminderSize
|
||||
self.smartMode = smartMode
|
||||
self.hasCompletedOnboarding = hasCompletedOnboarding
|
||||
self.launchAtLogin = launchAtLogin
|
||||
self.playSounds = playSounds
|
||||
@@ -79,6 +83,7 @@ struct AppSettings: Codable, Equatable, Hashable {
|
||||
postureTimer: TimerConfiguration(enabled: true, intervalSeconds: 30 * 60),
|
||||
userTimers: [],
|
||||
subtleReminderSize: .medium,
|
||||
smartMode: .defaults,
|
||||
hasCompletedOnboarding: false,
|
||||
launchAtLogin: false,
|
||||
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
|
||||
var remainingSeconds: Int
|
||||
var isPaused: Bool
|
||||
var pauseReasons: Set<PauseReason>
|
||||
var isActive: Bool
|
||||
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) {
|
||||
self.identifier = identifier
|
||||
self.remainingSeconds = intervalSeconds
|
||||
self.isPaused = isPaused
|
||||
self.pauseReasons = []
|
||||
self.isActive = isActive
|
||||
self.targetDate = Date().addingTimeInterval(Double(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
|
||||
private var enforceModeService: EnforceModeService?
|
||||
|
||||
// Smart Mode services
|
||||
private var fullscreenService: FullscreenDetectionService?
|
||||
private var idleService: IdleMonitoringService?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(settingsManager: SettingsManager) {
|
||||
self.settingsManager = settingsManager
|
||||
@@ -28,6 +33,72 @@ class TimerEngine: ObservableObject {
|
||||
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() {
|
||||
// If timers are already running, just update configurations without resetting
|
||||
@@ -163,23 +234,33 @@ class TimerEngine: ObservableObject {
|
||||
}
|
||||
|
||||
func pause() {
|
||||
for (id, _) in timerStates {
|
||||
timerStates[id]?.isPaused = true
|
||||
for (id, var state) in timerStates {
|
||||
state.pauseReasons.insert(.manual)
|
||||
state.isPaused = true
|
||||
timerStates[id] = state
|
||||
}
|
||||
}
|
||||
|
||||
func resume() {
|
||||
for (id, _) in timerStates {
|
||||
timerStates[id]?.isPaused = false
|
||||
for (id, var state) in timerStates {
|
||||
state.pauseReasons.remove(.manual)
|
||||
state.isPaused = !state.pauseReasons.isEmpty
|
||||
timerStates[id] = state
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
@@ -285,7 +366,11 @@ class TimerEngine: ObservableObject {
|
||||
/// - Pauses all active timers
|
||||
func handleSystemSleep() {
|
||||
sleepStartTime = Date()
|
||||
pause()
|
||||
for (id, var state) in timerStates {
|
||||
state.pauseReasons.insert(.system)
|
||||
state.isPaused = true
|
||||
timerStates[id] = state
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles system wake event
|
||||
@@ -305,11 +390,15 @@ class TimerEngine: ObservableObject {
|
||||
let elapsedSeconds = Int(Date().timeIntervalSince(sleepStart))
|
||||
|
||||
guard elapsedSeconds >= 1 else {
|
||||
resume()
|
||||
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 && !state.isPaused {
|
||||
for (identifier, state) in timerStates where state.isActive {
|
||||
var updatedState = state
|
||||
updatedState.remainingSeconds = max(0, state.remainingSeconds - elapsedSeconds)
|
||||
|
||||
@@ -317,9 +406,9 @@ class TimerEngine: ObservableObject {
|
||||
updatedState.remainingSeconds = 1
|
||||
}
|
||||
|
||||
updatedState.pauseReasons.remove(.system)
|
||||
updatedState.isPaused = !updatedState.pauseReasons.isEmpty
|
||||
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")
|
||||
}
|
||||
|
||||
SmartModeSetupView(settingsManager: settingsManager)
|
||||
.tag(5)
|
||||
.tabItem {
|
||||
Label("Smart Mode", systemImage: "brain.fill")
|
||||
}
|
||||
|
||||
GeneralSetupView(
|
||||
settingsManager: settingsManager,
|
||||
isOnboarding: false
|
||||
)
|
||||
.tag(5)
|
||||
.tag(6)
|
||||
.tabItem {
|
||||
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