diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift new file mode 100644 index 0000000..b37fb7b --- /dev/null +++ b/Gaze/AppDelegate.swift @@ -0,0 +1,194 @@ +// +// AppDelegate.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI +import AppKit +import Combine + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem? + private var popover: NSPopover? + private var timerEngine: TimerEngine? + private var settingsManager: SettingsManager? + private var reminderWindowController: NSWindowController? + private var cancellables = Set() + private var timerStateBeforeSleep: [TimerType: Date] = [:] + + func applicationDidFinishLaunching(_ notification: Notification) { + settingsManager = SettingsManager.shared + timerEngine = TimerEngine(settingsManager: settingsManager!) + + setupMenuBar() + setupLifecycleObservers() + + // Start timers if onboarding is complete + if settingsManager!.settings.hasCompletedOnboarding { + timerEngine?.start() + observeReminderEvents() + } + } + + func applicationWillTerminate(_ notification: Notification) { + settingsManager?.save() + timerEngine?.stop() + } + + private func setupLifecycleObservers() { + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(systemWillSleep), + name: NSWorkspace.willSleepNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(systemDidWake), + name: NSWorkspace.didWakeNotification, + object: nil + ) + } + + @objc private func systemWillSleep() { + // Save timer states + if let timerEngine = timerEngine { + for (type, state) in timerEngine.timerStates { + if state.isActive && !state.isPaused { + timerStateBeforeSleep[type] = Date() + } + } + } + timerEngine?.pause() + settingsManager?.save() + } + + @objc private func systemDidWake() { + guard let timerEngine = timerEngine else { return } + + let now = Date() + for (type, sleepTime) in timerStateBeforeSleep { + let elapsed = Int(now.timeIntervalSince(sleepTime)) + + if var state = timerEngine.timerStates[type] { + state.remainingSeconds = max(0, state.remainingSeconds - elapsed) + timerEngine.timerStates[type] = state + + // If timer expired during sleep, trigger it now + if state.remainingSeconds <= 0 { + timerEngine.timerStates[type]?.remainingSeconds = 1 + } + } + } + + timerStateBeforeSleep.removeAll() + timerEngine.resume() + } + + private func setupMenuBar() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem?.button { + button.image = NSImage(systemSymbolName: "eye.fill", accessibilityDescription: "Gaze") + button.action = #selector(togglePopover) + button.target = self + } + } + + @objc private func togglePopover() { + if let popover = popover, popover.isShown { + popover.close() + } else { + showPopover() + } + } + + private func showPopover() { + let popover = NSPopover() + popover.contentSize = NSSize(width: 300, height: 400) + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: MenuBarContentView( + timerEngine: timerEngine!, + settingsManager: settingsManager!, + onQuit: { NSApplication.shared.terminate(nil) } + ) + ) + + if let button = statusItem?.button { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + } + + self.popover = popover + } + + private func observeReminderEvents() { + timerEngine?.$activeReminder + .sink { [weak self] reminder in + guard let reminder = reminder else { + self?.dismissReminder() + return + } + self?.showReminder(reminder) + } + .store(in: &cancellables) + } + + private func showReminder(_ event: ReminderEvent) { + let contentView: AnyView + + switch event { + case .lookAwayTriggered(let countdownSeconds): + contentView = AnyView( + LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak self] in + self?.timerEngine?.dismissReminder() + } + ) + case .blinkTriggered: + contentView = AnyView( + BlinkReminderView { [weak self] in + self?.timerEngine?.dismissReminder() + } + ) + case .postureTriggered: + contentView = AnyView( + PostureReminderView { [weak self] in + self?.timerEngine?.dismissReminder() + } + ) + } + + showReminderWindow(contentView) + } + + private func showReminderWindow(_ content: AnyView) { + guard let screen = NSScreen.main else { return } + + let window = NSWindow( + contentRect: screen.frame, + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.level = .floating + window.isOpaque = false + window.backgroundColor = .clear + window.contentView = NSHostingView(rootView: content) + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + let windowController = NSWindowController(window: window) + windowController.showWindow(nil) + + reminderWindowController = windowController + } + + private func dismissReminder() { + reminderWindowController?.close() + reminderWindowController = nil + } +} diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index d5bb396..8cd4e23 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -9,9 +9,20 @@ import SwiftUI @main struct GazeApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject private var settingsManager = SettingsManager.shared + var body: some Scene { WindowGroup { - ContentView() + if settingsManager.settings.hasCompletedOnboarding { + EmptyView() + } else { + OnboardingContainerView(settingsManager: settingsManager) + } + } + .windowStyle(.hiddenTitleBar) + .commands { + CommandGroup(replacing: .newItem) { } } } } diff --git a/Gaze/Info.plist b/Gaze/Info.plist new file mode 100644 index 0000000..b2c1534 --- /dev/null +++ b/Gaze/Info.plist @@ -0,0 +1,20 @@ + + + + + LSUIElement + + CFBundleName + Gaze + CFBundleDisplayName + Gaze + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleShortVersionString + $(MARKETING_VERSION) + NSHumanReadableCopyright + Copyright © 2026 Mike Freno. All rights reserved. + + diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift new file mode 100644 index 0000000..1cd9d67 --- /dev/null +++ b/Gaze/Models/AppSettings.swift @@ -0,0 +1,40 @@ +// +// AppSettings.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import Foundation + +struct AppSettings: Codable, Equatable { + var lookAwayTimer: TimerConfiguration + var lookAwayCountdownSeconds: Int + var blinkTimer: TimerConfiguration + var postureTimer: TimerConfiguration + var hasCompletedOnboarding: Bool + var launchAtLogin: Bool + var playSounds: Bool + + static var defaults: AppSettings { + AppSettings( + lookAwayTimer: TimerConfiguration(enabled: true, intervalSeconds: 20 * 60), + lookAwayCountdownSeconds: 20, + blinkTimer: TimerConfiguration(enabled: true, intervalSeconds: 5 * 60), + postureTimer: TimerConfiguration(enabled: true, intervalSeconds: 30 * 60), + hasCompletedOnboarding: false, + launchAtLogin: false, + playSounds: true + ) + } + + static func == (lhs: AppSettings, rhs: AppSettings) -> Bool { + lhs.lookAwayTimer == rhs.lookAwayTimer && + lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds && + lhs.blinkTimer == rhs.blinkTimer && + lhs.postureTimer == rhs.postureTimer && + lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding && + lhs.launchAtLogin == rhs.launchAtLogin && + lhs.playSounds == rhs.playSounds + } +} diff --git a/Gaze/Models/ReminderEvent.swift b/Gaze/Models/ReminderEvent.swift new file mode 100644 index 0000000..55bbfc5 --- /dev/null +++ b/Gaze/Models/ReminderEvent.swift @@ -0,0 +1,25 @@ +// +// ReminderEvent.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import Foundation + +enum ReminderEvent: Equatable { + case lookAwayTriggered(countdownSeconds: Int) + case blinkTriggered + case postureTriggered + + var type: TimerType { + switch self { + case .lookAwayTriggered: + return .lookAway + case .blinkTriggered: + return .blink + case .postureTriggered: + return .posture + } + } +} diff --git a/Gaze/Models/TimerConfiguration.swift b/Gaze/Models/TimerConfiguration.swift new file mode 100644 index 0000000..4ca1c79 --- /dev/null +++ b/Gaze/Models/TimerConfiguration.swift @@ -0,0 +1,27 @@ +// +// TimerConfiguration.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import Foundation + +struct TimerConfiguration: Codable, Equatable { + var enabled: Bool + var intervalSeconds: Int + + init(enabled: Bool = true, intervalSeconds: Int) { + self.enabled = enabled + self.intervalSeconds = intervalSeconds + } + + var intervalMinutes: Int { + get { intervalSeconds / 60 } + set { intervalSeconds = newValue * 60 } + } + + static func == (lhs: TimerConfiguration, rhs: TimerConfiguration) -> Bool { + lhs.enabled == rhs.enabled && lhs.intervalSeconds == rhs.intervalSeconds + } +} diff --git a/Gaze/Models/TimerState.swift b/Gaze/Models/TimerState.swift new file mode 100644 index 0000000..6730db7 --- /dev/null +++ b/Gaze/Models/TimerState.swift @@ -0,0 +1,22 @@ +// +// TimerState.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import Foundation + +struct TimerState: Equatable { + let type: TimerType + var remainingSeconds: Int + var isPaused: Bool + var isActive: Bool + + init(type: TimerType, intervalSeconds: Int, isPaused: Bool = false, isActive: Bool = true) { + self.type = type + self.remainingSeconds = intervalSeconds + self.isPaused = isPaused + self.isActive = isActive + } +} diff --git a/Gaze/Models/TimerType.swift b/Gaze/Models/TimerType.swift new file mode 100644 index 0000000..9c554d3 --- /dev/null +++ b/Gaze/Models/TimerType.swift @@ -0,0 +1,38 @@ +// +// TimerType.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import Foundation + +enum TimerType: String, Codable, CaseIterable, Identifiable { + case lookAway + case blink + case posture + + var id: String { rawValue } + + var displayName: String { + switch self { + case .lookAway: + return "Look Away" + case .blink: + return "Blink" + case .posture: + return "Posture" + } + } + + var iconName: String { + switch self { + case .lookAway: + return "eye.fill" + case .blink: + return "eye.circle" + case .posture: + return "figure.stand" + } + } +} diff --git a/Gaze/Services/LaunchAtLoginManager.swift b/Gaze/Services/LaunchAtLoginManager.swift new file mode 100644 index 0000000..89fc102 --- /dev/null +++ b/Gaze/Services/LaunchAtLoginManager.swift @@ -0,0 +1,53 @@ +// +// LaunchAtLoginManager.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import Foundation +import ServiceManagement + +class LaunchAtLoginManager { + static var isEnabled: Bool { + if #available(macOS 13.0, *) { + return SMAppService.mainApp.status == .enabled + } else { + // Fallback for macOS 12 and earlier + return false + } + } + + static func enable() throws { + if #available(macOS 13.0, *) { + try SMAppService.mainApp.register() + } else { + throw LaunchAtLoginError.unsupportedOS + } + } + + static func disable() throws { + if #available(macOS 13.0, *) { + try SMAppService.mainApp.unregister() + } else { + throw LaunchAtLoginError.unsupportedOS + } + } + + static func toggle() { + do { + if isEnabled { + try disable() + } else { + try enable() + } + } catch { + print("Failed to toggle launch at login: \(error)") + } + } +} + +enum LaunchAtLoginError: Error { + case unsupportedOS + case registrationFailed +} diff --git a/Gaze/Services/SettingsManager.swift b/Gaze/Services/SettingsManager.swift new file mode 100644 index 0000000..74c9049 --- /dev/null +++ b/Gaze/Services/SettingsManager.swift @@ -0,0 +1,73 @@ +// +// SettingsManager.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import Foundation +import Combine + +@MainActor +class SettingsManager: ObservableObject { + static let shared = SettingsManager() + + @Published var settings: AppSettings { + didSet { + save() + } + } + + private let userDefaults = UserDefaults.standard + private let settingsKey = "gazeAppSettings" + + private init() { + self.settings = Self.loadSettings() + } + + private static func loadSettings() -> AppSettings { + guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings"), + let settings = try? JSONDecoder().decode(AppSettings.self, from: data) else { + return .defaults + } + return settings + } + + func save() { + guard let data = try? JSONEncoder().encode(settings) else { + print("Failed to encode settings") + return + } + userDefaults.set(data, forKey: settingsKey) + } + + func load() { + settings = Self.loadSettings() + } + + func resetToDefaults() { + settings = .defaults + } + + func timerConfiguration(for type: TimerType) -> TimerConfiguration { + switch type { + case .lookAway: + return settings.lookAwayTimer + case .blink: + return settings.blinkTimer + case .posture: + return settings.postureTimer + } + } + + func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { + switch type { + case .lookAway: + settings.lookAwayTimer = configuration + case .blink: + settings.blinkTimer = configuration + case .posture: + settings.postureTimer = configuration + } + } +} diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift new file mode 100644 index 0000000..410dcd1 --- /dev/null +++ b/Gaze/Services/TimerEngine.swift @@ -0,0 +1,132 @@ +// +// TimerEngine.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import Foundation +import Combine + +@MainActor +class TimerEngine: ObservableObject { + @Published var timerStates: [TimerType: TimerState] = [:] + @Published var activeReminder: ReminderEvent? + + private var timerSubscription: AnyCancellable? + private let settingsManager: SettingsManager + + nonisolated init(settingsManager: SettingsManager = .shared) { + self.settingsManager = settingsManager + } + + func start() { + stop() + + for timerType in TimerType.allCases { + let config = settingsManager.timerConfiguration(for: timerType) + if config.enabled { + timerStates[timerType] = TimerState( + type: timerType, + intervalSeconds: config.intervalSeconds, + isPaused: false, + isActive: true + ) + } + } + + 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 pause() { + for (type, _) in timerStates { + timerStates[type]?.isPaused = true + } + } + + func resume() { + for (type, _) in timerStates { + timerStates[type]?.isPaused = false + } + } + + func skipNext(type: TimerType) { + guard let state = timerStates[type] else { return } + let config = settingsManager.timerConfiguration(for: type) + timerStates[type] = TimerState( + type: type, + intervalSeconds: config.intervalSeconds, + isPaused: state.isPaused, + isActive: state.isActive + ) + } + + func dismissReminder() { + guard let reminder = activeReminder else { return } + activeReminder = nil + + skipNext(type: reminder.type) + + if case .lookAwayTriggered = reminder { + resume() + } + } + + private func handleTick() { + guard activeReminder == nil else { return } + + for (type, state) in timerStates { + guard state.isActive && !state.isPaused else { continue } + + timerStates[type]?.remainingSeconds -= 1 + + if let updatedState = timerStates[type], updatedState.remainingSeconds <= 0 { + triggerReminder(for: type) + break + } + } + } + + private func triggerReminder(for type: TimerType) { + switch type { + case .lookAway: + pause() + activeReminder = .lookAwayTriggered(countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds) + case .blink: + activeReminder = .blinkTriggered + case .posture: + activeReminder = .postureTriggered + } + } + + func getTimeRemaining(for type: TimerType) -> TimeInterval { + guard let state = timerStates[type] else { return 0 } + return TimeInterval(state.remainingSeconds) + } + + func getFormattedTimeRemaining(for type: TimerType) -> String { + let seconds = Int(getTimeRemaining(for: type)) + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + + if minutes >= 60 { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + return String(format: "%d:%02d:%02d", hours, remainingMinutes, remainingSeconds) + } else { + return String(format: "%d:%02d", minutes, remainingSeconds) + } + } +} diff --git a/Gaze/Views/Components/AnimatedFaceView.swift b/Gaze/Views/Components/AnimatedFaceView.swift new file mode 100644 index 0000000..58bb609 --- /dev/null +++ b/Gaze/Views/Components/AnimatedFaceView.swift @@ -0,0 +1,110 @@ +// +// AnimatedFaceView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct AnimatedFaceView: View { + @State private var eyeOffset: CGSize = .zero + @State private var animationStep = 0 + let size: CGFloat + + var body: some View { + ZStack { + // Face circle + Circle() + .fill(Color.yellow) + .frame(width: size, height: size) + + // Eyes + HStack(spacing: size * 0.2) { + Eye(offset: eyeOffset, size: size * 0.15) + Eye(offset: eyeOffset, size: size * 0.15) + } + .offset(y: -size * 0.1) + + // Smile + Arc(startAngle: .degrees(20), endAngle: .degrees(160), clockwise: false) + .stroke(Color.black, lineWidth: size * 0.05) + .frame(width: size * 0.5, height: size * 0.3) + .offset(y: size * 0.15) + } + .onAppear { + startAnimation() + } + } + + private func startAnimation() { + let sequence: [CGSize] = [ + .zero, // Center + CGSize(width: -15, height: 0), // Left + .zero, // Center + CGSize(width: 15, height: 0), // Right + .zero, // Center + CGSize(width: 0, height: -10), // Up + .zero // Center + ] + + animateSequence(sequence, index: 0) + } + + private func animateSequence(_ sequence: [CGSize], index: Int) { + guard index < sequence.count else { + // Loop the animation + animateSequence(sequence, index: 0) + return + } + + withAnimation(.easeInOut(duration: 0.8)) { + eyeOffset = sequence[index] + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + animateSequence(sequence, index: index + 1) + } + } +} + +struct Eye: View { + let offset: CGSize + let size: CGFloat + + var body: some View { + ZStack { + Circle() + .fill(Color.white) + .frame(width: size, height: size) + + Circle() + .fill(Color.black) + .frame(width: size * 0.5, height: size * 0.5) + .offset(offset) + } + } +} + +struct Arc: Shape { + var startAngle: Angle + var endAngle: Angle + var clockwise: Bool + + func path(in rect: CGRect) -> Path { + var path = Path() + path.addArc( + center: CGPoint(x: rect.midX, y: rect.minY), + radius: rect.width / 2, + startAngle: startAngle, + endAngle: endAngle, + clockwise: clockwise + ) + return path + } +} + +#Preview { + AnimatedFaceView(size: 200) + .frame(width: 400, height: 400) +} diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift new file mode 100644 index 0000000..8af6194 --- /dev/null +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -0,0 +1,178 @@ +// +// MenuBarContentView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct MenuBarContentView: View { + @ObservedObject var timerEngine: TimerEngine + @ObservedObject var settingsManager: SettingsManager + var onQuit: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack { + Image(systemName: "eye.fill") + .font(.title2) + .foregroundColor(.blue) + Text("Gaze") + .font(.title2) + .fontWeight(.semibold) + } + .padding() + + Divider() + + // Timer Status + if !timerEngine.timerStates.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Active Timers") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + .padding(.top, 8) + + ForEach(TimerType.allCases) { timerType in + if let state = timerEngine.timerStates[timerType] { + TimerStatusRow( + type: timerType, + state: state, + onSkip: { + timerEngine.skipNext(type: timerType) + } + ) + } + } + } + .padding(.bottom, 8) + + Divider() + } + + // Controls + VStack(spacing: 8) { + Button(action: { + if timerEngine.timerStates.values.first?.isPaused == true { + timerEngine.resume() + } else { + timerEngine.pause() + } + }) { + HStack { + Image(systemName: isPaused ? "play.circle" : "pause.circle") + Text(isPaused ? "Resume All Timers" : "Pause All Timers") + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal) + + Button(action: { + // TODO: Open settings window + }) { + HStack { + Image(systemName: "gearshape") + Text("Settings...") + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal) + } + .padding(.vertical, 8) + + Divider() + + // Quit + Button(action: onQuit) { + HStack { + Image(systemName: "power") + Text("Quit Gaze") + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding() + } + .frame(width: 300) + } + + private var isPaused: Bool { + timerEngine.timerStates.values.first?.isPaused ?? false + } +} + +struct TimerStatusRow: View { + let type: TimerType + let state: TimerState + var onSkip: () -> Void + + var body: some View { + HStack { + Image(systemName: type.iconName) + .foregroundColor(iconColor) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(type.displayName) + .font(.subheadline) + .fontWeight(.medium) + Text(timeRemaining) + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + + Spacer() + + Button(action: onSkip) { + Image(systemName: "forward.fill") + .font(.caption) + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .help("Skip to next \(type.displayName) reminder") + } + .padding(.horizontal) + .padding(.vertical, 4) + } + + private var iconColor: Color { + switch type { + case .lookAway: return .blue + case .blink: return .green + case .posture: return .orange + } + } + + private var timeRemaining: String { + let seconds = state.remainingSeconds + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + + if minutes >= 60 { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + return String(format: "%dh %dm", hours, remainingMinutes) + } else if minutes > 0 { + return String(format: "%dm %ds", minutes, remainingSeconds) + } else { + return String(format: "%ds", remainingSeconds) + } + } +} + +#Preview { + MenuBarContentView( + timerEngine: TimerEngine(settingsManager: .shared), + settingsManager: .shared, + onQuit: {} + ) +} diff --git a/Gaze/Views/Onboarding/BlinkSetupView.swift b/Gaze/Views/Onboarding/BlinkSetupView.swift new file mode 100644 index 0000000..779cc24 --- /dev/null +++ b/Gaze/Views/Onboarding/BlinkSetupView.swift @@ -0,0 +1,82 @@ +// +// BlinkSetupView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct BlinkSetupView: View { + @Binding var enabled: Bool + @Binding var intervalMinutes: Int + var onContinue: () -> Void + + var body: some View { + VStack(spacing: 30) { + Image(systemName: "eye.circle") + .font(.system(size: 60)) + .foregroundColor(.green) + + Text("Blink Reminder") + .font(.system(size: 28, weight: .bold)) + + Text("Keep your eyes hydrated") + .font(.title3) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 20) { + Toggle("Enable Blink Reminders", isOn: $enabled) + .font(.headline) + + if enabled { + VStack(alignment: .leading, spacing: 12) { + Text("Remind me every:") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack { + Slider(value: Binding( + get: { Double(intervalMinutes) }, + set: { intervalMinutes = Int($0) } + ), in: 1...15, step: 1) + + Text("\(intervalMinutes) min") + .frame(width: 60, alignment: .trailing) + .monospacedDigit() + } + } + } + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(12) + + InfoBox(text: "We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes") + + Spacer() + + Button(action: onContinue) { + Text("Continue") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .buttonStyle(.plain) + .padding(.horizontal, 40) + } + .frame(width: 600, height: 500) + .padding() + } +} + +#Preview { + BlinkSetupView( + enabled: .constant(true), + intervalMinutes: .constant(5), + onContinue: {} + ) +} diff --git a/Gaze/Views/Onboarding/CompletionView.swift b/Gaze/Views/Onboarding/CompletionView.swift new file mode 100644 index 0000000..9f26d4f --- /dev/null +++ b/Gaze/Views/Onboarding/CompletionView.swift @@ -0,0 +1,87 @@ +// +// CompletionView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct CompletionView: View { + var onComplete: () -> Void + + var body: some View { + VStack(spacing: 30) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 80)) + .foregroundColor(.green) + + Text("You're All Set!") + .font(.system(size: 36, weight: .bold)) + + Text("Gaze will now help you take care of your eyes and posture") + .font(.title3) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + VStack(alignment: .leading, spacing: 16) { + Text("What happens next:") + .font(.headline) + .padding(.horizontal) + + HStack(spacing: 16) { + Image(systemName: "menubar.rectangle") + .foregroundColor(.blue) + .frame(width: 30) + Text("Gaze will appear in your menu bar") + .font(.subheadline) + } + .padding(.horizontal) + + HStack(spacing: 16) { + Image(systemName: "clock") + .foregroundColor(.blue) + .frame(width: 30) + Text("Timers will start automatically") + .font(.subheadline) + } + .padding(.horizontal) + + HStack(spacing: 16) { + Image(systemName: "gearshape") + .foregroundColor(.blue) + .frame(width: 30) + Text("Adjust settings anytime from the menu bar") + .font(.subheadline) + } + .padding(.horizontal) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(12) + + Spacer() + + Button(action: onComplete) { + Text("Get Started") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(12) + } + .buttonStyle(.plain) + .padding(.horizontal, 40) + } + .frame(width: 600, height: 500) + .padding() + } +} + +#Preview { + CompletionView(onComplete: {}) +} diff --git a/Gaze/Views/Onboarding/LookAwaySetupView.swift b/Gaze/Views/Onboarding/LookAwaySetupView.swift new file mode 100644 index 0000000..b7e6df4 --- /dev/null +++ b/Gaze/Views/Onboarding/LookAwaySetupView.swift @@ -0,0 +1,117 @@ +// +// LookAwaySetupView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct LookAwaySetupView: View { + @Binding var enabled: Bool + @Binding var intervalMinutes: Int + @Binding var countdownSeconds: Int + var onContinue: () -> Void + var onBack: (() -> Void)? + + var body: some View { + VStack(spacing: 30) { + Image(systemName: "eye.fill") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text("Look Away Reminder") + .font(.system(size: 28, weight: .bold)) + + Text("Follow the 20-20-20 rule") + .font(.title3) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 20) { + Toggle("Enable Look Away Reminders", isOn: $enabled) + .font(.headline) + + if enabled { + VStack(alignment: .leading, spacing: 12) { + Text("Remind me every:") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack { + Slider(value: Binding( + get: { Double(intervalMinutes) }, + set: { intervalMinutes = Int($0) } + ), in: 5...60, step: 5) + + Text("\(intervalMinutes) min") + .frame(width: 60, alignment: .trailing) + .monospacedDigit() + } + + Text("Look away for:") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack { + Slider(value: Binding( + get: { Double(countdownSeconds) }, + set: { countdownSeconds = Int($0) } + ), in: 10...30, step: 5) + + Text("\(countdownSeconds) sec") + .frame(width: 60, alignment: .trailing) + .monospacedDigit() + } + } + } + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(12) + + InfoBox(text: "Every 20 minutes, look at something 20 feet away for 20 seconds to reduce eye strain") + + Spacer() + + Button(action: onContinue) { + Text("Continue") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .buttonStyle(.plain) + .padding(.horizontal, 40) + } + .frame(width: 600, height: 500) + .padding() + } +} + +struct InfoBox: View { + let text: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "info.circle") + .foregroundColor(.blue) + Text(text) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } +} + +#Preview { + LookAwaySetupView( + enabled: .constant(true), + intervalMinutes: .constant(20), + countdownSeconds: .constant(20), + onContinue: {} + ) +} diff --git a/Gaze/Views/Onboarding/OnboardingContainerView.swift b/Gaze/Views/Onboarding/OnboardingContainerView.swift new file mode 100644 index 0000000..4458809 --- /dev/null +++ b/Gaze/Views/Onboarding/OnboardingContainerView.swift @@ -0,0 +1,90 @@ +// +// OnboardingContainerView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct OnboardingContainerView: View { + @ObservedObject var settingsManager: SettingsManager + @State private var currentPage = 0 + @State private var lookAwayEnabled = true + @State private var lookAwayIntervalMinutes = 20 + @State private var lookAwayCountdownSeconds = 20 + @State private var blinkEnabled = true + @State private var blinkIntervalMinutes = 5 + @State private var postureEnabled = true + @State private var postureIntervalMinutes = 30 + + var body: some View { + VStack(spacing: 0) { + TabView(selection: $currentPage) { + WelcomeView(onContinue: { currentPage = 1 }) + .tag(0) + + LookAwaySetupView( + enabled: $lookAwayEnabled, + intervalMinutes: $lookAwayIntervalMinutes, + countdownSeconds: $lookAwayCountdownSeconds, + onContinue: { currentPage = 2 } + ) + .tag(1) + + BlinkSetupView( + enabled: $blinkEnabled, + intervalMinutes: $blinkIntervalMinutes, + onContinue: { currentPage = 3 } + ) + .tag(2) + + PostureSetupView( + enabled: $postureEnabled, + intervalMinutes: $postureIntervalMinutes, + onContinue: { currentPage = 4 } + ) + .tag(3) + + CompletionView( + onComplete: { + completeOnboarding() + } + ) + .tag(4) + } + .tabViewStyle(.automatic) + + // Page indicator + Text("\(currentPage + 1)/5") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.top, 8) + .padding(.bottom, 20) + } + } + + private func completeOnboarding() { + settingsManager.settings.lookAwayTimer = TimerConfiguration( + enabled: lookAwayEnabled, + intervalSeconds: lookAwayIntervalMinutes * 60 + ) + settingsManager.settings.lookAwayCountdownSeconds = lookAwayCountdownSeconds + + settingsManager.settings.blinkTimer = TimerConfiguration( + enabled: blinkEnabled, + intervalSeconds: blinkIntervalMinutes * 60 + ) + + settingsManager.settings.postureTimer = TimerConfiguration( + enabled: postureEnabled, + intervalSeconds: postureIntervalMinutes * 60 + ) + + settingsManager.settings.hasCompletedOnboarding = true + } +} + +#Preview { + OnboardingContainerView(settingsManager: SettingsManager.shared) +} diff --git a/Gaze/Views/Onboarding/PostureSetupView.swift b/Gaze/Views/Onboarding/PostureSetupView.swift new file mode 100644 index 0000000..b5b8f1e --- /dev/null +++ b/Gaze/Views/Onboarding/PostureSetupView.swift @@ -0,0 +1,82 @@ +// +// PostureSetupView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct PostureSetupView: View { + @Binding var enabled: Bool + @Binding var intervalMinutes: Int + var onContinue: () -> Void + + var body: some View { + VStack(spacing: 30) { + Image(systemName: "figure.stand") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("Posture Reminder") + .font(.system(size: 28, weight: .bold)) + + Text("Maintain proper ergonomics") + .font(.title3) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 20) { + Toggle("Enable Posture Reminders", isOn: $enabled) + .font(.headline) + + if enabled { + VStack(alignment: .leading, spacing: 12) { + Text("Remind me every:") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack { + Slider(value: Binding( + get: { Double(intervalMinutes) }, + set: { intervalMinutes = Int($0) } + ), in: 15...60, step: 5) + + Text("\(intervalMinutes) min") + .frame(width: 60, alignment: .trailing) + .monospacedDigit() + } + } + } + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(12) + + InfoBox(text: "Regular posture checks help prevent back and neck pain from prolonged sitting") + + Spacer() + + Button(action: onContinue) { + Text("Continue") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .buttonStyle(.plain) + .padding(.horizontal, 40) + } + .frame(width: 600, height: 500) + .padding() + } +} + +#Preview { + PostureSetupView( + enabled: .constant(true), + intervalMinutes: .constant(30), + onContinue: {} + ) +} diff --git a/Gaze/Views/Onboarding/WelcomeView.swift b/Gaze/Views/Onboarding/WelcomeView.swift new file mode 100644 index 0000000..8ad4ed4 --- /dev/null +++ b/Gaze/Views/Onboarding/WelcomeView.swift @@ -0,0 +1,79 @@ +// +// WelcomeView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct WelcomeView: View { + var onContinue: () -> Void + + var body: some View { + VStack(spacing: 30) { + Spacer() + + Image(systemName: "eye.fill") + .font(.system(size: 80)) + .foregroundColor(.blue) + + Text("Welcome to Gaze") + .font(.system(size: 36, weight: .bold)) + + Text("Take care of your eyes and posture") + .font(.title3) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 16) { + FeatureRow(icon: "eye.circle", title: "Reduce Eye Strain", description: "Regular breaks help prevent digital eye strain") + FeatureRow(icon: "eye.trianglebadge.exclamationmark", title: "Remember to Blink", description: "We blink less when focused on screens") + FeatureRow(icon: "figure.stand", title: "Maintain Good Posture", description: "Gentle reminders to sit up straight") + } + .padding() + + Spacer() + + Button(action: onContinue) { + Text("Let's Get Started") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .buttonStyle(.plain) + .padding(.horizontal, 40) + } + .frame(width: 600, height: 500) + .padding() + } +} + +struct FeatureRow: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(alignment: .top, spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(.blue) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } +} + +#Preview { + WelcomeView(onContinue: {}) +} diff --git a/Gaze/Views/Reminders/BlinkReminderView.swift b/Gaze/Views/Reminders/BlinkReminderView.swift new file mode 100644 index 0000000..72f6f1d --- /dev/null +++ b/Gaze/Views/Reminders/BlinkReminderView.swift @@ -0,0 +1,141 @@ +// +// BlinkReminderView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct BlinkReminderView: View { + var onDismiss: () -> Void + + @State private var opacity: Double = 0 + @State private var blinkState: BlinkState = .open + @State private var blinkCount = 0 + + enum BlinkState { + case open + case closed + } + + var body: some View { + VStack { + RoundedRectangle(cornerRadius: 20) + .fill(Color.white) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) + .frame(width: 100, height: 100) + .overlay( + BlinkingFace(isOpen: blinkState == .open) + ) + } + .opacity(opacity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, NSScreen.main?.frame.height ?? 800 * 0.1) + .onAppear { + startAnimation() + } + } + + private func startAnimation() { + // Fade in + withAnimation(.easeIn(duration: 0.3)) { + opacity = 1.0 + } + + // Start blinking after fade in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + performBlinks() + } + } + + private func performBlinks() { + let blinkDuration = 0.1 + let pauseBetweenBlinks = 0.5 + + func blink() { + // Close eyes + withAnimation(.linear(duration: blinkDuration)) { + blinkState = .closed + } + + // Open eyes + DispatchQueue.main.asyncAfter(deadline: .now() + blinkDuration) { + withAnimation(.linear(duration: blinkDuration)) { + blinkState = .open + } + + blinkCount += 1 + + if blinkCount < 3 { + // Pause before next blink + DispatchQueue.main.asyncAfter(deadline: .now() + pauseBetweenBlinks) { + blink() + } + } else { + // Fade out after all blinks + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + fadeOut() + } + } + } + } + + blink() + } + + private func fadeOut() { + withAnimation(.easeOut(duration: 0.3)) { + opacity = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + onDismiss() + } + } +} + +struct BlinkingFace: View { + let isOpen: Bool + + var body: some View { + ZStack { + // Simple face + Circle() + .fill(Color.yellow) + .frame(width: 60, height: 60) + + // Eyes + HStack(spacing: 12) { + if isOpen { + Circle() + .fill(Color.black) + .frame(width: 8, height: 8) + Circle() + .fill(Color.black) + .frame(width: 8, height: 8) + } else { + // Closed eyes (lines) + Rectangle() + .fill(Color.black) + .frame(width: 10, height: 2) + Rectangle() + .fill(Color.black) + .frame(width: 10, height: 2) + } + } + .offset(y: -8) + + // Smile + Arc(startAngle: .degrees(20), endAngle: .degrees(160), clockwise: false) + .stroke(Color.black, lineWidth: 2) + .frame(width: 30, height: 15) + .offset(y: 10) + } + } +} + +#Preview { + BlinkReminderView(onDismiss: {}) + .frame(width: 800, height: 600) +} diff --git a/Gaze/Views/Reminders/LookAwayReminderView.swift b/Gaze/Views/Reminders/LookAwayReminderView.swift new file mode 100644 index 0000000..0e458d3 --- /dev/null +++ b/Gaze/Views/Reminders/LookAwayReminderView.swift @@ -0,0 +1,118 @@ +// +// LookAwayReminderView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct LookAwayReminderView: View { + let countdownSeconds: Int + var onDismiss: () -> Void + + @State private var remainingSeconds: Int + @State private var timer: Timer? + + init(countdownSeconds: Int, onDismiss: @escaping () -> Void) { + self.countdownSeconds = countdownSeconds + self.onDismiss = onDismiss + self._remainingSeconds = State(initialValue: countdownSeconds) + } + + var body: some View { + ZStack { + // Semi-transparent dark background + Color.black.opacity(0.85) + .ignoresSafeArea() + + VStack(spacing: 40) { + Text("Look Away") + .font(.system(size: 64, weight: .bold)) + .foregroundColor(.white) + + Text("Look at something 20 feet away") + .font(.system(size: 28)) + .foregroundColor(.white.opacity(0.9)) + + AnimatedFaceView(size: 200) + .padding(.vertical, 30) + + // Countdown display + ZStack { + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 8) + .frame(width: 120, height: 120) + + Circle() + .trim(from: 0, to: progress) + .stroke(Color.blue, lineWidth: 8) + .frame(width: 120, height: 120) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1), value: progress) + + Text("\(remainingSeconds)") + .font(.system(size: 48, weight: .bold)) + .foregroundColor(.white) + .monospacedDigit() + } + + Text("Press ESC or Space to skip") + .font(.subheadline) + .foregroundColor(.white.opacity(0.6)) + } + + // Skip button in corner + VStack { + HStack { + Spacer() + Button(action: dismiss) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 32)) + .foregroundColor(.white.opacity(0.7)) + } + .buttonStyle(.plain) + .padding(30) + } + Spacer() + } + } + .onAppear { + startCountdown() + } + .onDisappear { + timer?.invalidate() + } + .onKeyPress(.escape) { + dismiss() + return .handled + } + .onKeyPress(.space) { + dismiss() + return .handled + } + } + + private var progress: CGFloat { + CGFloat(remainingSeconds) / CGFloat(countdownSeconds) + } + + private func startCountdown() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + if remainingSeconds > 0 { + remainingSeconds -= 1 + } else { + dismiss() + } + } + } + + private func dismiss() { + timer?.invalidate() + onDismiss() + } +} + +#Preview { + LookAwayReminderView(countdownSeconds: 20, onDismiss: {}) +} diff --git a/Gaze/Views/Reminders/PostureReminderView.swift b/Gaze/Views/Reminders/PostureReminderView.swift new file mode 100644 index 0000000..2f956bc --- /dev/null +++ b/Gaze/Views/Reminders/PostureReminderView.swift @@ -0,0 +1,69 @@ +// +// PostureReminderView.swift +// Gaze +// +// Created by Mike Freno on 1/7/26. +// + +import SwiftUI + +struct PostureReminderView: View { + var onDismiss: () -> Void + + @State private var scale: CGFloat = 0 + @State private var yOffset: CGFloat = 0 + @State private var opacity: Double = 0 + + private let screenHeight = NSScreen.main?.frame.height ?? 800 + private let screenWidth = NSScreen.main?.frame.width ?? 1200 + + var body: some View { + VStack { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: scale)) + .foregroundColor(.black) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) + } + .opacity(opacity) + .offset(y: yOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, screenHeight * 0.1) + .onAppear { + startAnimation() + } + } + + private func startAnimation() { + // Phase 1: Fade in + Grow to 10% screen width + withAnimation(.easeOut(duration: 0.4)) { + opacity = 1.0 + scale = screenWidth * 0.1 + } + + // Phase 2: Hold + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4 + 0.5) { + // Phase 3: Shrink to 5% + withAnimation(.easeInOut(duration: 0.3)) { + scale = screenWidth * 0.05 + } + + // Phase 4: Shoot upward + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.easeIn(duration: 0.4)) { + yOffset = -screenHeight + opacity = 0 + } + + // Dismiss after animation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + onDismiss() + } + } + } + } +} + +#Preview { + PostureReminderView(onDismiss: {}) + .frame(width: 800, height: 600) +} diff --git a/GazeTests/SettingsManagerTests.swift b/GazeTests/SettingsManagerTests.swift new file mode 100644 index 0000000..ddc2d13 --- /dev/null +++ b/GazeTests/SettingsManagerTests.swift @@ -0,0 +1,125 @@ +// +// SettingsManagerTests.swift +// GazeTests +// +// Created by Mike Freno on 1/7/26. +// + +import XCTest +@testable import Gaze + +@MainActor +final class SettingsManagerTests: XCTestCase { + + var settingsManager: SettingsManager! + + override func setUp() async throws { + try await super.setUp() + settingsManager = SettingsManager.shared + // Clear any existing settings + UserDefaults.standard.removeObject(forKey: "gazeAppSettings") + settingsManager.load() + } + + override func tearDown() async throws { + UserDefaults.standard.removeObject(forKey: "gazeAppSettings") + try await super.tearDown() + } + + func testDefaultSettings() { + let defaults = AppSettings.defaults + + XCTAssertTrue(defaults.lookAwayTimer.enabled) + XCTAssertEqual(defaults.lookAwayTimer.intervalSeconds, 20 * 60) + XCTAssertEqual(defaults.lookAwayCountdownSeconds, 20) + + XCTAssertTrue(defaults.blinkTimer.enabled) + XCTAssertEqual(defaults.blinkTimer.intervalSeconds, 5 * 60) + + XCTAssertTrue(defaults.postureTimer.enabled) + XCTAssertEqual(defaults.postureTimer.intervalSeconds, 30 * 60) + + XCTAssertFalse(defaults.hasCompletedOnboarding) + XCTAssertFalse(defaults.launchAtLogin) + XCTAssertTrue(defaults.playSounds) + } + + func testSaveAndLoad() { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = false + settings.lookAwayCountdownSeconds = 30 + settings.hasCompletedOnboarding = true + + settingsManager.settings = settings + + settingsManager.load() + + XCTAssertFalse(settingsManager.settings.lookAwayTimer.enabled) + XCTAssertEqual(settingsManager.settings.lookAwayCountdownSeconds, 30) + XCTAssertTrue(settingsManager.settings.hasCompletedOnboarding) + } + + func testTimerConfigurationRetrieval() { + let lookAwayConfig = settingsManager.timerConfiguration(for: .lookAway) + XCTAssertTrue(lookAwayConfig.enabled) + XCTAssertEqual(lookAwayConfig.intervalSeconds, 20 * 60) + + let blinkConfig = settingsManager.timerConfiguration(for: .blink) + XCTAssertTrue(blinkConfig.enabled) + XCTAssertEqual(blinkConfig.intervalSeconds, 5 * 60) + + let postureConfig = settingsManager.timerConfiguration(for: .posture) + XCTAssertTrue(postureConfig.enabled) + XCTAssertEqual(postureConfig.intervalSeconds, 30 * 60) + } + + func testUpdateTimerConfiguration() { + var newConfig = TimerConfiguration(enabled: false, intervalSeconds: 10 * 60) + settingsManager.updateTimerConfiguration(for: .lookAway, configuration: newConfig) + + let retrieved = settingsManager.timerConfiguration(for: .lookAway) + XCTAssertFalse(retrieved.enabled) + XCTAssertEqual(retrieved.intervalSeconds, 10 * 60) + } + + func testResetToDefaults() { + settingsManager.settings.lookAwayTimer.enabled = false + settingsManager.settings.hasCompletedOnboarding = true + + settingsManager.resetToDefaults() + + XCTAssertTrue(settingsManager.settings.lookAwayTimer.enabled) + XCTAssertFalse(settingsManager.settings.hasCompletedOnboarding) + } + + func testCodableEncoding() { + let settings = AppSettings.defaults + + let encoder = JSONEncoder() + let data = try? encoder.encode(settings) + + XCTAssertNotNil(data) + } + + func testCodableDecoding() { + let settings = AppSettings.defaults + + let encoder = JSONEncoder() + let data = try! encoder.encode(settings) + + let decoder = JSONDecoder() + let decoded = try? decoder.decode(AppSettings.self, from: data) + + XCTAssertNotNil(decoded) + XCTAssertEqual(decoded, settings) + } + + func testTimerConfigurationIntervalMinutes() { + var config = TimerConfiguration(enabled: true, intervalSeconds: 600) + + XCTAssertEqual(config.intervalMinutes, 10) + + config.intervalMinutes = 20 + XCTAssertEqual(config.intervalSeconds, 1200) + } +} diff --git a/GazeTests/TimerEngineTests.swift b/GazeTests/TimerEngineTests.swift new file mode 100644 index 0000000..c558dd3 --- /dev/null +++ b/GazeTests/TimerEngineTests.swift @@ -0,0 +1,144 @@ +// +// TimerEngineTests.swift +// GazeTests +// +// Created by Mike Freno on 1/7/26. +// + +import XCTest +@testable import Gaze + +@MainActor +final class TimerEngineTests: XCTestCase { + + var timerEngine: TimerEngine! + var settingsManager: SettingsManager! + + override func setUp() async throws { + try await super.setUp() + settingsManager = SettingsManager.shared + UserDefaults.standard.removeObject(forKey: "gazeAppSettings") + settingsManager.load() + timerEngine = TimerEngine(settingsManager: settingsManager) + } + + override func tearDown() async throws { + timerEngine.stop() + UserDefaults.standard.removeObject(forKey: "gazeAppSettings") + try await super.tearDown() + } + + func testTimerInitialization() { + timerEngine.start() + + XCTAssertEqual(timerEngine.timerStates.count, 3) + XCTAssertNotNil(timerEngine.timerStates[.lookAway]) + XCTAssertNotNil(timerEngine.timerStates[.blink]) + XCTAssertNotNil(timerEngine.timerStates[.posture]) + } + + func testDisabledTimersNotInitialized() { + settingsManager.settings.blinkTimer.enabled = false + + timerEngine.start() + + XCTAssertEqual(timerEngine.timerStates.count, 2) + XCTAssertNotNil(timerEngine.timerStates[.lookAway]) + XCTAssertNil(timerEngine.timerStates[.blink]) + XCTAssertNotNil(timerEngine.timerStates[.posture]) + } + + func testTimerStateInitialValues() { + timerEngine.start() + + let lookAwayState = timerEngine.timerStates[.lookAway]! + XCTAssertEqual(lookAwayState.type, .lookAway) + XCTAssertEqual(lookAwayState.remainingSeconds, 20 * 60) + XCTAssertFalse(lookAwayState.isPaused) + XCTAssertTrue(lookAwayState.isActive) + } + + func testPauseAllTimers() { + timerEngine.start() + timerEngine.pause() + + for (_, state) in timerEngine.timerStates { + XCTAssertTrue(state.isPaused) + } + } + + func testResumeAllTimers() { + timerEngine.start() + timerEngine.pause() + timerEngine.resume() + + for (_, state) in timerEngine.timerStates { + XCTAssertFalse(state.isPaused) + } + } + + func testSkipNext() { + settingsManager.settings.lookAwayTimer.intervalSeconds = 60 + timerEngine.start() + + timerEngine.timerStates[.lookAway]?.remainingSeconds = 10 + + timerEngine.skipNext(type: .lookAway) + + XCTAssertEqual(timerEngine.timerStates[.lookAway]?.remainingSeconds, 60) + } + + func testGetTimeRemaining() { + timerEngine.start() + + let timeRemaining = timerEngine.getTimeRemaining(for: .lookAway) + XCTAssertEqual(timeRemaining, TimeInterval(20 * 60)) + } + + func testGetFormattedTimeRemaining() { + timerEngine.start() + timerEngine.timerStates[.lookAway]?.remainingSeconds = 125 + + let formatted = timerEngine.getFormattedTimeRemaining(for: .lookAway) + XCTAssertEqual(formatted, "2:05") + } + + func testGetFormattedTimeRemainingWithHours() { + timerEngine.start() + timerEngine.timerStates[.lookAway]?.remainingSeconds = 3665 + + let formatted = timerEngine.getFormattedTimeRemaining(for: .lookAway) + XCTAssertEqual(formatted, "1:01:05") + } + + func testStop() { + timerEngine.start() + XCTAssertFalse(timerEngine.timerStates.isEmpty) + + timerEngine.stop() + XCTAssertTrue(timerEngine.timerStates.isEmpty) + } + + func testDismissReminderResetsTimer() { + timerEngine.start() + timerEngine.timerStates[.blink]?.remainingSeconds = 0 + timerEngine.activeReminder = .blinkTriggered + + timerEngine.dismissReminder() + + XCTAssertNil(timerEngine.activeReminder) + XCTAssertEqual(timerEngine.timerStates[.blink]?.remainingSeconds, 5 * 60) + } + + func testDismissLookAwayResumesTimers() { + timerEngine.start() + timerEngine.activeReminder = .lookAwayTriggered(countdownSeconds: 20) + timerEngine.pause() + + timerEngine.dismissReminder() + + for (_, state) in timerEngine.timerStates { + XCTAssertFalse(state.isPaused) + } + } +}