getting going

This commit is contained in:
Michael Freno
2026-01-08 00:31:34 -05:00
parent c2c5736bfd
commit 658bbd8e02
24 changed files with 2058 additions and 1 deletions

194
Gaze/AppDelegate.swift Normal file
View File

@@ -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<AnyCancellable>()
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
}
}

View File

@@ -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) { }
}
}
}

20
Gaze/Info.plist Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
<key>CFBundleName</key>
<string>Gaze</string>
<key>CFBundleDisplayName</key>
<string>Gaze</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Mike Freno. All rights reserved.</string>
</dict>
</plist>

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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"
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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: {}
)
}

View File

@@ -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: {}
)
}

View File

@@ -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: {})
}

View File

@@ -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: {}
)
}

View File

@@ -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)
}

View File

@@ -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: {}
)
}

View File

@@ -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: {})
}

View File

@@ -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)
}

View File

@@ -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: {})
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}