getting going
This commit is contained in:
194
Gaze/AppDelegate.swift
Normal file
194
Gaze/AppDelegate.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
20
Gaze/Info.plist
Normal 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>
|
||||
40
Gaze/Models/AppSettings.swift
Normal file
40
Gaze/Models/AppSettings.swift
Normal 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
|
||||
}
|
||||
}
|
||||
25
Gaze/Models/ReminderEvent.swift
Normal file
25
Gaze/Models/ReminderEvent.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Gaze/Models/TimerConfiguration.swift
Normal file
27
Gaze/Models/TimerConfiguration.swift
Normal 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
|
||||
}
|
||||
}
|
||||
22
Gaze/Models/TimerState.swift
Normal file
22
Gaze/Models/TimerState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
38
Gaze/Models/TimerType.swift
Normal file
38
Gaze/Models/TimerType.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
53
Gaze/Services/LaunchAtLoginManager.swift
Normal file
53
Gaze/Services/LaunchAtLoginManager.swift
Normal 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
|
||||
}
|
||||
73
Gaze/Services/SettingsManager.swift
Normal file
73
Gaze/Services/SettingsManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
132
Gaze/Services/TimerEngine.swift
Normal file
132
Gaze/Services/TimerEngine.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
Gaze/Views/Components/AnimatedFaceView.swift
Normal file
110
Gaze/Views/Components/AnimatedFaceView.swift
Normal 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)
|
||||
}
|
||||
178
Gaze/Views/MenuBar/MenuBarContentView.swift
Normal file
178
Gaze/Views/MenuBar/MenuBarContentView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
82
Gaze/Views/Onboarding/BlinkSetupView.swift
Normal file
82
Gaze/Views/Onboarding/BlinkSetupView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
87
Gaze/Views/Onboarding/CompletionView.swift
Normal file
87
Gaze/Views/Onboarding/CompletionView.swift
Normal 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: {})
|
||||
}
|
||||
117
Gaze/Views/Onboarding/LookAwaySetupView.swift
Normal file
117
Gaze/Views/Onboarding/LookAwaySetupView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
90
Gaze/Views/Onboarding/OnboardingContainerView.swift
Normal file
90
Gaze/Views/Onboarding/OnboardingContainerView.swift
Normal 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)
|
||||
}
|
||||
82
Gaze/Views/Onboarding/PostureSetupView.swift
Normal file
82
Gaze/Views/Onboarding/PostureSetupView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
79
Gaze/Views/Onboarding/WelcomeView.swift
Normal file
79
Gaze/Views/Onboarding/WelcomeView.swift
Normal 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: {})
|
||||
}
|
||||
141
Gaze/Views/Reminders/BlinkReminderView.swift
Normal file
141
Gaze/Views/Reminders/BlinkReminderView.swift
Normal 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)
|
||||
}
|
||||
118
Gaze/Views/Reminders/LookAwayReminderView.swift
Normal file
118
Gaze/Views/Reminders/LookAwayReminderView.swift
Normal 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: {})
|
||||
}
|
||||
69
Gaze/Views/Reminders/PostureReminderView.swift
Normal file
69
Gaze/Views/Reminders/PostureReminderView.swift
Normal 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)
|
||||
}
|
||||
125
GazeTests/SettingsManagerTests.swift
Normal file
125
GazeTests/SettingsManagerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
144
GazeTests/TimerEngineTests.swift
Normal file
144
GazeTests/TimerEngineTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user