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
|
@main
|
||||||
struct GazeApp: App {
|
struct GazeApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
@StateObject private var settingsManager = SettingsManager.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
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