This commit is contained in:
Michael Freno
2026-01-29 19:34:55 -05:00
parent 1a43a2a1a0
commit 30af29e1d9
21 changed files with 307 additions and 440 deletions

View File

@@ -16,6 +16,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private let windowManager: WindowManaging
private var updateManager: UpdateManager?
private var systemSleepManager: SystemSleepManager?
private var fullscreenService: FullscreenDetectionService?
private var idleService: IdleMonitoringService?
private var usageTrackingService: UsageTrackingService?
private var cancellables = Set<AnyCancellable>()
private var hasStartedTimers = false
@@ -53,7 +56,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
)
systemSleepManager?.startObserving()
serviceContainer.setupSmartModeServices()
setupSmartModeServices()
// Initialize update manager after onboarding is complete
if settingsManager.settings.hasCompletedOnboarding {
@@ -82,18 +85,45 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
.removeDuplicates()
.sink { [weak self] smartMode in
guard let self = self else { return }
self.serviceContainer.idleService?.updateThreshold(
self.idleService?.updateThreshold(
minutes: smartMode.idleThresholdMinutes)
self.serviceContainer.usageTrackingService?.updateResetThreshold(
self.usageTrackingService?.updateResetThreshold(
minutes: smartMode.usageResetAfterMinutes)
// Force state check when settings change to apply immediately
self.serviceContainer.fullscreenService?.forceUpdate()
self.serviceContainer.idleService?.forceUpdate()
self.fullscreenService?.forceUpdate()
self.idleService?.forceUpdate()
}
.store(in: &cancellables)
}
private func setupSmartModeServices() {
let settings = settingsManager.settings
Task { @MainActor in
fullscreenService = await FullscreenDetectionService.create()
idleService = IdleMonitoringService(
idleThresholdMinutes: settings.smartMode.idleThresholdMinutes
)
if settings.smartMode.trackUsage {
usageTrackingService = UsageTrackingService(
resetThresholdMinutes: settings.smartMode.usageResetAfterMinutes
)
} else {
usageTrackingService = nil
}
if let idleService = idleService {
usageTrackingService?.setupIdleMonitoring(idleService)
}
timerEngine?.setupSmartMode(
fullscreenService: fullscreenService,
idleService: idleService
)
}
}
func onboardingCompleted() {
startTimers()

View File

@@ -1,101 +0,0 @@
//
// EyeDebugStateAdapter.swift
// Gaze
//
// Debug state storage for eye tracking UI.
//
import AppKit
import Foundation
@MainActor
final class EyeDebugStateAdapter {
var leftPupilRatio: Double?
var rightPupilRatio: Double?
var leftVerticalRatio: Double?
var rightVerticalRatio: Double?
var yaw: Double?
var pitch: Double?
var enableDebugLogging: Bool = false {
didSet {
PupilDetector.enableDiagnosticLogging = enableDebugLogging
}
}
var leftEyeInput: NSImage?
var rightEyeInput: NSImage?
var leftEyeProcessed: NSImage?
var rightEyeProcessed: NSImage?
var leftPupilPosition: PupilPosition?
var rightPupilPosition: PupilPosition?
var leftEyeSize: CGSize?
var rightEyeSize: CGSize?
var leftEyeRegion: EyeRegion?
var rightEyeRegion: EyeRegion?
var imageSize: CGSize?
var gazeDirection: GazeDirection {
guard let leftH = leftPupilRatio,
let rightH = rightPupilRatio,
let leftV = leftVerticalRatio,
let rightV = rightVerticalRatio else {
return .center
}
let avgHorizontal = (leftH + rightH) / 2.0
let avgVertical = (leftV + rightV) / 2.0
return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical)
}
func update(from result: EyeTrackingProcessingResult) {
leftPupilRatio = result.leftPupilRatio
rightPupilRatio = result.rightPupilRatio
leftVerticalRatio = result.leftVerticalRatio
rightVerticalRatio = result.rightVerticalRatio
yaw = result.yaw
pitch = result.pitch
}
func updateEyeImages(from detector: PupilDetector.Type) {
if let leftInput = detector.debugLeftEyeInput {
leftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height))
}
if let rightInput = detector.debugRightEyeInput {
rightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height))
}
if let leftProcessed = detector.debugLeftEyeProcessed {
leftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height))
}
if let rightProcessed = detector.debugRightEyeProcessed {
rightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height))
}
leftPupilPosition = detector.debugLeftPupilPosition
rightPupilPosition = detector.debugRightPupilPosition
leftEyeSize = detector.debugLeftEyeSize
rightEyeSize = detector.debugRightEyeSize
leftEyeRegion = detector.debugLeftEyeRegion
rightEyeRegion = detector.debugRightEyeRegion
imageSize = detector.debugImageSize
}
func clear() {
leftPupilRatio = nil
rightPupilRatio = nil
leftVerticalRatio = nil
rightVerticalRatio = nil
yaw = nil
pitch = nil
leftEyeInput = nil
rightEyeInput = nil
leftEyeProcessed = nil
rightEyeProcessed = nil
leftPupilPosition = nil
rightPupilPosition = nil
leftEyeSize = nil
rightEyeSize = nil
leftEyeRegion = nil
rightEyeRegion = nil
imageSize = nil
}
}

View File

@@ -1,21 +0,0 @@
//
// EyeTrackingProcessingResult.swift
// Gaze
//
// Shared processing result for eye tracking pipeline.
//
import Foundation
struct EyeTrackingProcessingResult: Sendable {
let faceDetected: Bool
let isEyesClosed: Bool
let userLookingAtScreen: Bool
let leftPupilRatio: Double?
let rightPupilRatio: Double?
let leftVerticalRatio: Double?
let rightVerticalRatio: Double?
let yaw: Double?
let pitch: Double?
let faceWidthRatio: Double?
}

View File

@@ -217,3 +217,97 @@ enum EyeTrackingError: Error, LocalizedError {
}
}
}
// MARK: - Debug State Adapter
@MainActor
final class EyeDebugStateAdapter {
var leftPupilRatio: Double?
var rightPupilRatio: Double?
var leftVerticalRatio: Double?
var rightVerticalRatio: Double?
var yaw: Double?
var pitch: Double?
var enableDebugLogging: Bool = false {
didSet {
PupilDetector.enableDiagnosticLogging = enableDebugLogging
}
}
var leftEyeInput: NSImage?
var rightEyeInput: NSImage?
var leftEyeProcessed: NSImage?
var rightEyeProcessed: NSImage?
var leftPupilPosition: PupilPosition?
var rightPupilPosition: PupilPosition?
var leftEyeSize: CGSize?
var rightEyeSize: CGSize?
var leftEyeRegion: EyeRegion?
var rightEyeRegion: EyeRegion?
var imageSize: CGSize?
var gazeDirection: GazeDirection {
guard let leftH = leftPupilRatio,
let rightH = rightPupilRatio,
let leftV = leftVerticalRatio,
let rightV = rightVerticalRatio else {
return .center
}
let avgHorizontal = (leftH + rightH) / 2.0
let avgVertical = (leftV + rightV) / 2.0
return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical)
}
func update(from result: EyeTrackingProcessingResult) {
leftPupilRatio = result.leftPupilRatio
rightPupilRatio = result.rightPupilRatio
leftVerticalRatio = result.leftVerticalRatio
rightVerticalRatio = result.rightVerticalRatio
yaw = result.yaw
pitch = result.pitch
}
func updateEyeImages(from detector: PupilDetector.Type) {
if let leftInput = detector.debugLeftEyeInput {
leftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height))
}
if let rightInput = detector.debugRightEyeInput {
rightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height))
}
if let leftProcessed = detector.debugLeftEyeProcessed {
leftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height))
}
if let rightProcessed = detector.debugRightEyeProcessed {
rightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height))
}
leftPupilPosition = detector.debugLeftPupilPosition
rightPupilPosition = detector.debugRightPupilPosition
leftEyeSize = detector.debugLeftEyeSize
rightEyeSize = detector.debugRightEyeSize
leftEyeRegion = detector.debugLeftEyeRegion
rightEyeRegion = detector.debugRightEyeRegion
imageSize = detector.debugImageSize
}
func clear() {
leftPupilRatio = nil
rightPupilRatio = nil
leftVerticalRatio = nil
rightVerticalRatio = nil
yaw = nil
pitch = nil
leftEyeInput = nil
rightEyeInput = nil
leftEyeProcessed = nil
rightEyeProcessed = nil
leftPupilPosition = nil
rightPupilPosition = nil
leftEyeSize = nil
rightEyeSize = nil
leftEyeRegion = nil
rightEyeRegion = nil
imageSize = nil
}
}

View File

@@ -9,6 +9,19 @@ import Foundation
import Vision
import simd
struct EyeTrackingProcessingResult: Sendable {
let faceDetected: Bool
let isEyesClosed: Bool
let userLookingAtScreen: Bool
let leftPupilRatio: Double?
let rightPupilRatio: Double?
let leftVerticalRatio: Double?
let rightVerticalRatio: Double?
let yaw: Double?
let pitch: Double?
let faceWidthRatio: Double?
}
final class GazeDetector: @unchecked Sendable {
struct GazeResult: Sendable {
let isLookingAway: Bool

View File

@@ -1,21 +0,0 @@
//
// FullscreenWindowMatcher.swift
// Gaze
//
// Created by Mike Freno on 1/29/26.
//
import CoreGraphics
struct FullscreenWindowMatcher {
func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool {
screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) }
}
private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool {
abs(windowBounds.width - screenFrame.width) < tolerance
&& abs(windowBounds.height - screenFrame.height) < tolerance
&& abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance
&& abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance
}
}

View File

@@ -1,68 +0,0 @@
//
// 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
guard let self = self else { return }
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()
}
}

View File

@@ -1,160 +0,0 @@
//
// MockWindowManager.swift
// Gaze
//
// Mock implementation of WindowManaging for testing purposes.
//
import SwiftUI
/// Mock window manager that tracks window operations without creating actual windows.
/// Useful for unit testing UI flows and state management.
@MainActor
final class MockWindowManager: WindowManaging {
// MARK: - State Tracking
private(set) var isOverlayReminderVisible = false
private(set) var isSubtleReminderVisible = false
// MARK: - Operation History
struct WindowOperation {
let timestamp: Date
let operation: Operation
enum Operation {
case showOverlayReminder
case showSubtleReminder
case dismissOverlayReminder
case dismissSubtleReminder
case dismissAllReminders
case showSettings(initialTab: Int)
case showOnboarding
}
}
private(set) var operations: [WindowOperation] = []
// MARK: - Callbacks for Testing
var onShowOverlayReminder: (() -> Void)?
var onShowSubtleReminder: (() -> Void)?
var onDismissOverlayReminder: (() -> Void)?
var onDismissSubtleReminder: (() -> Void)?
var onShowSettings: ((Int) -> Void)?
var onShowOnboarding: (() -> Void)?
// MARK: - WindowManaging Implementation
func showReminderWindow<Content: View>(_ content: Content, windowType: ReminderWindowType) {
let operation: WindowOperation.Operation
switch windowType {
case .overlay:
isOverlayReminderVisible = true
operation = .showOverlayReminder
onShowOverlayReminder?()
case .subtle:
isSubtleReminderVisible = true
operation = .showSubtleReminder
onShowSubtleReminder?()
}
operations.append(WindowOperation(timestamp: Date(), operation: operation))
}
func dismissOverlayReminder() {
isOverlayReminderVisible = false
operations.append(WindowOperation(timestamp: Date(), operation: .dismissOverlayReminder))
onDismissOverlayReminder?()
}
func dismissSubtleReminder() {
isSubtleReminderVisible = false
operations.append(WindowOperation(timestamp: Date(), operation: .dismissSubtleReminder))
onDismissSubtleReminder?()
}
func dismissAllReminders() {
isOverlayReminderVisible = false
isSubtleReminderVisible = false
operations.append(WindowOperation(timestamp: Date(), operation: .dismissAllReminders))
onDismissOverlayReminder?()
onDismissSubtleReminder?()
}
func showSettings(settingsManager: any SettingsProviding, initialTab: Int) {
operations.append(WindowOperation(timestamp: Date(), operation: .showSettings(initialTab: initialTab)))
onShowSettings?(initialTab)
}
func showOnboarding(settingsManager: any SettingsProviding) {
operations.append(WindowOperation(timestamp: Date(), operation: .showOnboarding))
onShowOnboarding?()
}
// MARK: - Test Helpers
/// Resets all state for a fresh test
func reset() {
isOverlayReminderVisible = false
isSubtleReminderVisible = false
operations.removeAll()
onShowOverlayReminder = nil
onShowSubtleReminder = nil
onDismissOverlayReminder = nil
onDismissSubtleReminder = nil
onShowSettings = nil
onShowOnboarding = nil
}
/// Returns the number of times a specific operation was performed
func operationCount(_ operationType: WindowOperation.Operation) -> Int {
operations.filter { operation in
switch (operation.operation, operationType) {
case (.showOverlayReminder, .showOverlayReminder),
(.showSubtleReminder, .showSubtleReminder),
(.dismissOverlayReminder, .dismissOverlayReminder),
(.dismissSubtleReminder, .dismissSubtleReminder),
(.dismissAllReminders, .dismissAllReminders),
(.showOnboarding, .showOnboarding):
return true
case (.showSettings(let tab1), .showSettings(let tab2)):
return tab1 == tab2
default:
return false
}
}.count
}
/// Returns true if the operation was performed at least once
func didPerformOperation(_ operationType: WindowOperation.Operation) -> Bool {
operationCount(operationType) > 0
}
/// Returns the last operation performed, if any
var lastOperation: WindowOperation? {
operations.last
}
}
// MARK: - Equatable Conformance for Testing
extension MockWindowManager.WindowOperation.Operation: Equatable {
static func == (lhs: MockWindowManager.WindowOperation.Operation, rhs: MockWindowManager.WindowOperation.Operation) -> Bool {
switch (lhs, rhs) {
case (.showOverlayReminder, .showOverlayReminder),
(.showSubtleReminder, .showSubtleReminder),
(.dismissOverlayReminder, .dismissOverlayReminder),
(.dismissSubtleReminder, .dismissSubtleReminder),
(.dismissAllReminders, .dismissAllReminders),
(.showOnboarding, .showOnboarding):
return true
case (.showSettings(let tab1), .showSettings(let tab2)):
return tab1 == tab2
default:
return false
}
}
}

View File

@@ -23,15 +23,6 @@ final class ServiceContainer {
/// The timer engine instance (created lazily)
private var _timerEngine: TimerEngine?
/// The fullscreen detection service
private(set) var fullscreenService: FullscreenDetectionService?
/// The idle monitoring service
private(set) var idleService: IdleMonitoringService?
/// The usage tracking service
private(set) var usageTrackingService: UsageTrackingService?
/// Creates a production container with real services
private init() {
self.settingsManager = SettingsManager.shared
@@ -64,30 +55,4 @@ final class ServiceContainer {
return engine
}
/// Sets up smart mode services
func setupSmartModeServices() {
let settings = settingsManager.settings
Task { @MainActor in
fullscreenService = await FullscreenDetectionService.create()
idleService = IdleMonitoringService(
idleThresholdMinutes: settings.smartMode.idleThresholdMinutes
)
usageTrackingService = UsageTrackingService(
resetThresholdMinutes: 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
)
}
}
}

View File

@@ -191,3 +191,16 @@ final class FullscreenDetectionService: ObservableObject {
}
#endif
}
struct FullscreenWindowMatcher {
func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool {
screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) }
}
private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool {
abs(windowBounds.width - screenFrame.width) < tolerance
&& abs(windowBounds.height - screenFrame.height) < tolerance
&& abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance
&& abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance
}
}

View File

@@ -1,23 +1,82 @@
//
// UsageTrackingService.swift
// 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
guard let self = self else { return }
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()
}
}
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)
}
@@ -26,20 +85,20 @@ struct UsageStatistics: Codable {
@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
@@ -51,14 +110,14 @@ class UsageTrackingService: ObservableObject {
sessionStartDate: Date()
)
}
checkForReset()
startTracking()
}
func setupIdleMonitoring(_ idleService: IdleMonitoringService) {
self.idleService = idleService
idleService.$isIdle
.sink { [weak self] isIdle in
Task { @MainActor in
@@ -67,12 +126,12 @@ class UsageTrackingService: ObservableObject {
}
.store(in: &cancellables)
}
func updateResetThreshold(minutes: Int) {
resetThresholdMinutes = minutes
checkForReset()
}
private func startTracking() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
@@ -81,50 +140,50 @@ class UsageTrackingService: ObservableObject {
}
}
}
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,
@@ -135,31 +194,31 @@ class UsageTrackingService: ObservableObject {
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 {

View File

@@ -0,0 +1,64 @@
//
// MockWindowManager.swift
// GazeTests
//
// Mock window manager for tests.
//
import Foundation
import SwiftUI
@testable import Gaze
@MainActor
final class MockWindowManager: WindowManaging {
private(set) var didShowOnboarding = false
private(set) var didShowSettings = false
private(set) var didShowReminder = false
private(set) var didDismissReminder = false
var isOverlayReminderVisible: Bool = false
var isSubtleReminderVisible: Bool = false
func showOnboarding(settingsManager: any SettingsProviding) {
didShowOnboarding = true
}
func showSettings(settingsManager: any SettingsProviding, initialTab: Int) {
didShowSettings = true
}
func showReminderWindow<Content: View>(_ content: Content, windowType: ReminderWindowType) {
didShowReminder = true
switch windowType {
case .overlay:
isOverlayReminderVisible = true
case .subtle:
isSubtleReminderVisible = true
}
}
func dismissOverlayReminder() {
didDismissReminder = true
isOverlayReminderVisible = false
}
func dismissSubtleReminder() {
didDismissReminder = true
isSubtleReminderVisible = false
}
func dismissAllReminders() {
didDismissReminder = true
isOverlayReminderVisible = false
isSubtleReminderVisible = false
}
func reset() {
didShowOnboarding = false
didShowSettings = false
didShowReminder = false
didDismissReminder = false
isOverlayReminderVisible = false
isSubtleReminderVisible = false
}
}