From a0962e596a4a0cb74c5150649c6845d685226c62 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 8 Jan 2026 20:26:49 -0500 Subject: [PATCH] general:add settings window --- Gaze/AppDelegate.swift | 125 +++++++++++----- Gaze/Views/MenuBar/MenuBarContentView.swift | 6 +- .../Onboarding/OnboardingContainerView.swift | 2 +- .../Onboarding/SettingsOnboardingView.swift | 20 +-- Gaze/Views/SettingsWindowView.swift | 137 ++++++++++++++++++ 5 files changed, 239 insertions(+), 51 deletions(-) create mode 100644 Gaze/Views/SettingsWindowView.swift diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 4c69cdf..cebe1a3 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -11,16 +11,20 @@ import Combine @MainActor class AppDelegate: NSObject, NSApplicationDelegate { + var timerEngine: TimerEngine? private var statusItem: NSStatusItem? private var popover: NSPopover? - private var timerEngine: TimerEngine? private var settingsManager: SettingsManager? private var reminderWindowController: NSWindowController? + private var settingsWindowController: NSWindowController? private var cancellables = Set() private var timerStateBeforeSleep: [TimerType: Date] = [:] private var hasStartedTimers = false func applicationDidFinishLaunching(_ notification: Notification) { + // Set activation policy to hide dock icon + NSApplication.shared.setActivationPolicy(.accessory) + settingsManager = SettingsManager.shared timerEngine = TimerEngine(settingsManager: settingsManager!) @@ -38,6 +42,44 @@ class AppDelegate: NSObject, NSApplicationDelegate { startTimers() } + 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) }, + onOpenSettings: { [weak self] in self?.openSettings() } + ) + ) + + if let button = statusItem?.button { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + } + + self.popover = popover + } + private func startTimers() { guard !hasStartedTimers else { return } hasStartedTimers = true @@ -50,6 +92,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { .sink { [weak self] settings in if settings.hasCompletedOnboarding { self?.startTimers() + } else if self?.hasStartedTimers == true { + // Restart timers when settings change (only if already started) + self?.timerEngine?.start() } } .store(in: &cancellables) @@ -111,43 +156,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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 @@ -218,4 +226,45 @@ class AppDelegate: NSObject, NSApplicationDelegate { func getMenuBarIconPosition() -> NSRect? { return statusItem?.button?.window?.frame } + + // Public method to open settings window + func openSettings() { + // If window already exists, just bring it to front + if let existingWindow = settingsWindowController?.window { + existingWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 550), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + window.title = "Settings" + window.center() + window.setFrameAutosaveName("SettingsWindow") + window.isReleasedWhenClosed = false + window.contentView = NSHostingView( + rootView: SettingsWindowView(settingsManager: settingsManager!) + ) + + let windowController = NSWindowController(window: window) + windowController.showWindow(nil) + + settingsWindowController = windowController + + NSApp.activate(ignoringOtherApps: true) + + // Observe when window is closed to clean up reference + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + self?.settingsWindowController = nil + } + } } diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index 0560740..f62edba 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -46,6 +46,7 @@ struct MenuBarContentView: View { @ObservedObject var timerEngine: TimerEngine @ObservedObject var settingsManager: SettingsManager var onQuit: () -> Void + var onOpenSettings: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -108,7 +109,7 @@ struct MenuBarContentView: View { .buttonStyle(MenuBarHoverButtonStyle()) Button(action: { - // TODO: Open settings window + onOpenSettings() }) { HStack { Image(systemName: "gearshape") @@ -230,6 +231,7 @@ struct TimerStatusRow: View { MenuBarContentView( timerEngine: timerEngine, settingsManager: settingsManager, - onQuit: {} + onQuit: {}, + onOpenSettings: {} ) } diff --git a/Gaze/Views/Onboarding/OnboardingContainerView.swift b/Gaze/Views/Onboarding/OnboardingContainerView.swift index 3feb586..24de23f 100644 --- a/Gaze/Views/Onboarding/OnboardingContainerView.swift +++ b/Gaze/Views/Onboarding/OnboardingContainerView.swift @@ -102,7 +102,7 @@ struct OnboardingContainerView: View { minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .center ) - .foregroundColor(.black) + .foregroundColor(.primary) } .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10)) } diff --git a/Gaze/Views/Onboarding/SettingsOnboardingView.swift b/Gaze/Views/Onboarding/SettingsOnboardingView.swift index ff16dec..98226ac 100644 --- a/Gaze/Views/Onboarding/SettingsOnboardingView.swift +++ b/Gaze/Views/Onboarding/SettingsOnboardingView.swift @@ -9,24 +9,24 @@ import SwiftUI struct SettingsOnboardingView: View { @Binding var launchAtLogin: Bool - + var body: some View { VStack(spacing: 30) { Spacer() - + Image(systemName: "gearshape.fill") .font(.system(size: 80)) .foregroundColor(.blue) - + Text("Final Settings") .font(.system(size: 36, weight: .bold)) - + Text("Configure app preferences and support the project") .font(.title3) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 40) - + VStack(spacing: 20) { // Launch at Login Toggle HStack { @@ -46,13 +46,13 @@ struct SettingsOnboardingView: View { } .padding() .glassEffect(.regular, in: .rect(cornerRadius: 12)) - + // Links Section VStack(spacing: 12) { Text("Support & Contribute") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) - + // GitHub Link Button(action: { if let url = URL(string: "https://github.com/mikefreno/Gaze") { @@ -79,7 +79,7 @@ struct SettingsOnboardingView: View { } .buttonStyle(.plain) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10)) - + // Buy Me a Coffee Button(action: { if let url = URL(string: "https://buymeacoffee.com/placeholder") { @@ -112,14 +112,14 @@ struct SettingsOnboardingView: View { } .padding() } - + Spacer() } .frame(width: 600, height: 450) .padding() .background(.clear) } - + private func applyLaunchAtLoginSetting(enabled: Bool) { do { if enabled { diff --git a/Gaze/Views/SettingsWindowView.swift b/Gaze/Views/SettingsWindowView.swift new file mode 100644 index 0000000..af09e1b --- /dev/null +++ b/Gaze/Views/SettingsWindowView.swift @@ -0,0 +1,137 @@ +// +// SettingsWindowView.swift +// Gaze +// +// Created by Mike Freno on 1/8/26. +// + +import SwiftUI + +struct SettingsWindowView: View { + @ObservedObject var settingsManager: SettingsManager + @State private var currentTab = 0 + @State private var lookAwayEnabled: Bool + @State private var lookAwayIntervalMinutes: Int + @State private var lookAwayCountdownSeconds: Int + @State private var blinkEnabled: Bool + @State private var blinkIntervalMinutes: Int + @State private var postureEnabled: Bool + @State private var postureIntervalMinutes: Int + @State private var launchAtLogin: Bool + + init(settingsManager: SettingsManager) { + self.settingsManager = settingsManager + + _lookAwayEnabled = State(initialValue: settingsManager.settings.lookAwayTimer.enabled) + _lookAwayIntervalMinutes = State(initialValue: settingsManager.settings.lookAwayTimer.intervalSeconds / 60) + _lookAwayCountdownSeconds = State(initialValue: settingsManager.settings.lookAwayCountdownSeconds) + _blinkEnabled = State(initialValue: settingsManager.settings.blinkTimer.enabled) + _blinkIntervalMinutes = State(initialValue: settingsManager.settings.blinkTimer.intervalSeconds / 60) + _postureEnabled = State(initialValue: settingsManager.settings.postureTimer.enabled) + _postureIntervalMinutes = State(initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60) + _launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin) + } + + var body: some View { + VStack(spacing: 0) { + TabView(selection: $currentTab) { + LookAwaySetupView( + enabled: $lookAwayEnabled, + intervalMinutes: $lookAwayIntervalMinutes, + countdownSeconds: $lookAwayCountdownSeconds + ) + .tag(0) + .tabItem { + Label("Look Away", systemImage: "eye.fill") + } + + BlinkSetupView( + enabled: $blinkEnabled, + intervalMinutes: $blinkIntervalMinutes + ) + .tag(1) + .tabItem { + Label("Blink", systemImage: "eye.circle.fill") + } + + PostureSetupView( + enabled: $postureEnabled, + intervalMinutes: $postureIntervalMinutes + ) + .tag(2) + .tabItem { + Label("Posture", systemImage: "figure.stand") + } + + SettingsOnboardingView( + launchAtLogin: $launchAtLogin + ) + .tag(3) + .tabItem { + Label("General", systemImage: "gearshape.fill") + } + } + .padding() + + Divider() + + HStack { + Spacer() + + Button("Cancel") { + closeWindow() + } + .keyboardShortcut(.escape) + + Button("Apply") { + applySettings() + closeWindow() + } + .keyboardShortcut(.return) + .buttonStyle(.borderedProminent) + } + .padding() + } + .frame(width: 600, height: 550) + } + + private func applySettings() { + 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.launchAtLogin = launchAtLogin + + do { + if launchAtLogin { + try LaunchAtLoginManager.enable() + } else { + try LaunchAtLoginManager.disable() + } + } catch { + print("Failed to set launch at login: \(error)") + } + } + + private func closeWindow() { + if let window = NSApplication.shared.windows.first(where: { $0.title == "Settings" }) { + window.close() + } + } +} + +#Preview { + SettingsWindowView(settingsManager: SettingsManager.shared) +}